mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea486a7e2f | |||
| d474a94a21 | |||
| c1a4cc308b | |||
| 210da98b69 | |||
| 2fc183d384 | |||
| 78fb111610 | |||
| 11c0cb4b61 | |||
| 95831f7c7f | |||
| a31e7bfaa5 | |||
| 6e35fc1769 | |||
| 48cded1646 | |||
| db752cee15 | |||
| b33aae0a73 | |||
| 72126ad736 | |||
| 4a2eeac90b | |||
| 46be3e7d70 | |||
| 6d140532a7 | |||
| 8c4a7f1518 |
@@ -111,27 +111,21 @@ jobs:
|
||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||
|
||||
const body = `## 🚨 PR Size Warning
|
||||
|
||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
||||
|
||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
||||
|
||||
### 💡 Suggestions:
|
||||
- **Split by feature or module** - Break down into logical, independent pieces
|
||||
- **Create a sequence of PRs** - Each building on the previous one
|
||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
||||
|
||||
### 📊 What was counted:
|
||||
- ✅ Source files, stylesheets, configuration files
|
||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
||||
|
||||
### 📚 Guidelines:
|
||||
- **Ideal:** 300-500 lines per PR
|
||||
- **Warning:** 500-800 lines
|
||||
- **Critical:** 800+ lines ⚠️
|
||||
|
||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
||||
const body = '## 🚨 PR Size Warning\n\n' +
|
||||
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' +
|
||||
'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' +
|
||||
'### 💡 Suggestions:\n' +
|
||||
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
|
||||
'- **Create a sequence of PRs** - Each building on the previous one\n' +
|
||||
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
|
||||
'### 📊 What was counted:\n' +
|
||||
'- ✅ Source files, stylesheets, configuration files\n' +
|
||||
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
|
||||
'### 📚 Guidelines:\n' +
|
||||
'- **Ideal:** 300-500 lines per PR\n' +
|
||||
'- **Warning:** 500-800 lines\n' +
|
||||
'- **Critical:** 800+ lines ⚠️\n\n' +
|
||||
'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.';
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
|
||||
@@ -62,3 +62,4 @@ branch.json
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
stats.html
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@typescript-eslint/parser": "8.48.0",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"esbuild": "0.27.0",
|
||||
"esbuild": "0.27.1",
|
||||
"eslint-plugin-storybook": "10.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.0.8",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
public/
|
||||
playwright/
|
||||
dist/
|
||||
coverage/
|
||||
vendor/
|
||||
@@ -104,6 +104,9 @@ RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.n
|
||||
COPY --from=installer /app/apps/web/public ./apps/web/public
|
||||
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
|
||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ export const OrganizationBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
|
||||
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const SecurityListTip = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.general.security_list_tip")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-700">
|
||||
{t("environments.settings.general.security_list_tip_link")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+2
@@ -12,6 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
@@ -48,6 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.organization_name")}
|
||||
description={t("environments.settings.general.organization_name_description")}>
|
||||
|
||||
+3
-1
@@ -21,6 +21,7 @@ import {
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
@@ -99,6 +100,7 @@ const elementIcons = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
ipAddress: NetworkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -82,6 +82,7 @@ const mockPipelineInput = {
|
||||
},
|
||||
country: "USA",
|
||||
action: "Action Name",
|
||||
ipAddress: "203.0.113.7",
|
||||
} as TResponseMeta,
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
@@ -346,7 +347,7 @@ describe("handleIntegrations", () => {
|
||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||
// Adjust expectations for metadata and recalled question
|
||||
const expectedMetadataString =
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name\nIP Address: 203.0.113.7";
|
||||
expect(airtableWriteData).toHaveBeenCalledWith(
|
||||
mockAirtableIntegration.config.key,
|
||||
mockAirtableIntegration.config.data[0],
|
||||
|
||||
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
|
||||
if (metadata.country) result.push(`Country: ${metadata.country}`);
|
||||
if (metadata.action) result.push(`Action: ${metadata.action}`);
|
||||
if (metadata.ipAddress) result.push(`IP Address: ${metadata.ipAddress}`);
|
||||
|
||||
// Join all the elements in the result array with a newline for formatting
|
||||
return result.join("\n");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -8,6 +9,7 @@ import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
@@ -90,28 +92,50 @@ export const POST = async (request: Request) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
const webhookPromises = webhooks.map((webhook) => {
|
||||
const body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate Standard Webhooks headers
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
};
|
||||
|
||||
// Add signature if webhook has a secret configured
|
||||
if (webhook.secret) {
|
||||
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
||||
webhookMessageId,
|
||||
webhookTimestamp,
|
||||
body,
|
||||
webhook.secret
|
||||
);
|
||||
}
|
||||
|
||||
return fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
|
||||
@@ -11,6 +11,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
@@ -136,6 +137,13 @@ export const POST = withV1ApiWrapper({
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -59,6 +63,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
@@ -144,6 +149,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -4,12 +4,15 @@ import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
url: webhookInput.url,
|
||||
@@ -17,6 +20,7 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -119,6 +120,13 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { buildBlock } from "./survey-block-builder";
|
||||
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.next": "Next",
|
||||
"common.back": "Back",
|
||||
"": "",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}) as unknown as TFunction;
|
||||
|
||||
describe("survey-block-builder", () => {
|
||||
describe("buildBlock", () => {
|
||||
const mockElements = [
|
||||
{
|
||||
id: "element-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: createI18nString("Test Question", []),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
];
|
||||
|
||||
test("should use getDefaultButtonLabel when buttonLabel is provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
buttonLabel: "Custom Next",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.buttonLabel).toEqual({
|
||||
default: "Custom Next",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use createI18nString with empty translation when buttonLabel is not provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.buttonLabel).toEqual({
|
||||
default: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use getDefaultBackButtonLabel when backButtonLabel is provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
backButtonLabel: "Custom Back",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.backButtonLabel).toEqual({
|
||||
default: "Custom Back",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use createI18nString with empty translation when backButtonLabel is not provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.backButtonLabel).toEqual({
|
||||
default: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -302,7 +302,9 @@ export const buildBlock = ({
|
||||
elements,
|
||||
logic,
|
||||
logicFallback,
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
|
||||
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : createI18nString(t(""), []),
|
||||
backButtonLabel: backButtonLabel
|
||||
? getDefaultBackButtonLabel(backButtonLabel, t)
|
||||
: createI18nString(t(""), []),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4913,6 +4913,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
metadata: {},
|
||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||
slug: null,
|
||||
|
||||
@@ -61,6 +61,10 @@ checksums:
|
||||
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
|
||||
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
|
||||
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
||||
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
|
||||
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
|
||||
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
|
||||
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
|
||||
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
auth/signup/title: 96addc349f834eaa5d14c786d5478b1c
|
||||
auth/signup_without_verification_success/user_successfully_created: ff849ebedc5dacb36493d7894f16edc7
|
||||
@@ -170,6 +174,7 @@ checksums:
|
||||
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/domain: 402d46965eacc3af4c5df92e53e95712
|
||||
common/done: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
common/download: 56b7d0834952b39ee394b44bd8179178
|
||||
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
||||
@@ -736,20 +741,26 @@ checksums:
|
||||
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
|
||||
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
|
||||
environments/integrations/webhooks/all_current_and_new_surveys: 4c0e0e94bf2dea0cf58568d11cfbb71d
|
||||
environments/integrations/webhooks/copy_secret_now: 23d6da66a541e7c22eb06729b6eac376
|
||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
||||
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
||||
environments/integrations/webhooks/response_finished: 71764de45369a08aacc290af629fa298
|
||||
environments/integrations/webhooks/response_updated: 0b178ffeb39b615db0db036a685f118b
|
||||
environments/integrations/webhooks/secret_copy_warning: 55ac31fc9ee192a66093ba4b6ccd0a91
|
||||
environments/integrations/webhooks/secret_description: e9ab6e0fd78d49c3e25ee649c62061bd
|
||||
environments/integrations/webhooks/signing_secret: 91594fa8588e4232e155a65d07419bf7
|
||||
environments/integrations/webhooks/source: 6e87903ef260da661b2bf6d858ba68ca
|
||||
environments/integrations/webhooks/test_endpoint: 9ce47af3f982224071e16d5a17190a60
|
||||
environments/integrations/webhooks/triggers: 66488f38662a4199fb8a18967239c992
|
||||
environments/integrations/webhooks/webhook_added_successfully: 2d8e8d7a158ea8e4b65e67900363527b
|
||||
environments/integrations/webhooks/webhook_created: ffb4449a8d50bb83097485ddabb73562
|
||||
environments/integrations/webhooks/webhook_delete_confirmation: b5bae9856effd32053669c0e0a22479f
|
||||
environments/integrations/webhooks/webhook_deleted_successfully: fcefd247ec76a372002d2cffac3c5b0f
|
||||
environments/integrations/webhooks/webhook_name_placeholder: ffa3274cf83d8dc05c882fbf61c48f8f
|
||||
@@ -947,6 +958,8 @@ checksums:
|
||||
environments/settings/general/remove_logo: f60f1803e6fc8017b1eae7c30089107f
|
||||
environments/settings/general/replace_logo: e3c8bec7574a670607e88771164e272f
|
||||
environments/settings/general/resend_invitation_email: 6305d1ffa015c377ef59fe9c2661cf02
|
||||
environments/settings/general/security_list_tip: 0bbed89fa5265da7e07767087f87c736
|
||||
environments/settings/general/security_list_tip_link: ccdb1a21610ebf5a626d813b155be4ba
|
||||
environments/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
|
||||
environments/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
|
||||
environments/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
|
||||
@@ -1119,6 +1132,8 @@ checksums:
|
||||
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
|
||||
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
||||
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
|
||||
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
|
||||
environments/surveys/edit/capture_ip_address_description: 932d1b4ad68594d06d4eaf0212f9570c
|
||||
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
|
||||
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
||||
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
||||
@@ -1569,6 +1584,7 @@ checksums:
|
||||
environments/surveys/responses/error_downloading_responses: 97a79108cfc854834d09cf14c300a291
|
||||
environments/surveys/responses/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
environments/surveys/responses/how_to_identify_users: c886035d9d9a0cfc3fa9703972001044
|
||||
environments/surveys/responses/ip_address: 8f2b4d42a165a4c165eca4d7639ce57e
|
||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||
|
||||
+143
-13
@@ -1,8 +1,11 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
// Import after unmocking
|
||||
import {
|
||||
generateStandardWebhookSignature,
|
||||
generateWebhookSecret,
|
||||
getWebhookSecretBytes,
|
||||
hashSecret,
|
||||
hashSha256,
|
||||
parseApiKeyV2,
|
||||
@@ -283,6 +286,133 @@ describe("Crypto Utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook Signature Functions", () => {
|
||||
describe("generateWebhookSecret", () => {
|
||||
test("should generate a secret with whsec_ prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
expect(secret.startsWith("whsec_")).toBe(true);
|
||||
});
|
||||
|
||||
test("should generate base64-encoded content after prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const base64Part = secret.slice(6); // Remove "whsec_"
|
||||
|
||||
// Should be valid base64
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
|
||||
// Should decode to 32 bytes (256 bits)
|
||||
const decoded = Buffer.from(base64Part, "base64");
|
||||
expect(decoded.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should generate unique secrets each time", () => {
|
||||
const secret1 = generateWebhookSecret();
|
||||
const secret2 = generateWebhookSecret();
|
||||
expect(secret1).not.toBe(secret2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebhookSecretBytes", () => {
|
||||
test("should decode whsec_ prefixed secret to bytes", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const bytes = getWebhookSecretBytes(secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should handle secret without whsec_ prefix", () => {
|
||||
const base64Secret = Buffer.from("test-secret-bytes-32-characters!").toString("base64");
|
||||
const bytes = getWebhookSecretBytes(base64Secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.toString()).toBe("test-secret-bytes-32-characters!");
|
||||
});
|
||||
|
||||
test("should correctly decode a known secret", () => {
|
||||
// Create a known secret
|
||||
const knownBytes = Buffer.from("known-test-secret-for-testing!!");
|
||||
const secret = `whsec_${knownBytes.toString("base64")}`;
|
||||
|
||||
const decoded = getWebhookSecretBytes(secret);
|
||||
expect(decoded.toString()).toBe("known-test-secret-for-testing!!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStandardWebhookSignature", () => {
|
||||
test("should generate signature in v1,{base64} format", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const signature = generateStandardWebhookSignature("msg_123", 1704547200, '{"test":"data"}', secret);
|
||||
|
||||
expect(signature.startsWith("v1,")).toBe(true);
|
||||
const base64Part = signature.slice(3);
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
});
|
||||
|
||||
test("should generate deterministic signatures for same inputs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
expect(sig1).toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different payloads", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"a"}', secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"b"}', secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different timestamps", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, 1704547200, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, 1704547201, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different webhook IDs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature("msg_1", timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature("msg_2", timestamp, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should produce verifiable signatures", () => {
|
||||
// This test verifies the signature can be verified using the same algorithm
|
||||
const secretBytes = Buffer.from("test-secret-32-bytes-exactly!!!");
|
||||
const secret = `whsec_${secretBytes.toString("base64")}`;
|
||||
const webhookId = "msg_verify";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"verify"}';
|
||||
|
||||
const signature = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
// Manually compute the expected signature
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const expectedSig = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
|
||||
expect(signature).toBe(`v1,${expectedSig}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCM decryption failure logging", () => {
|
||||
// Test key - 32 bytes for AES-256
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -314,11 +444,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called with the correct format (object first, message second)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
||||
@@ -342,11 +472,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
||||
@@ -366,11 +496,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+52
-1
@@ -1,5 +1,5 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
|
||||
@@ -141,3 +141,54 @@ export const parseApiKeyV2 = (key: string): { secret: string } | null => {
|
||||
|
||||
return { secret };
|
||||
};
|
||||
|
||||
// Standard Webhooks secret prefix
|
||||
const WEBHOOK_SECRET_PREFIX = "whsec_";
|
||||
|
||||
/**
|
||||
* Generate a Standard Webhooks compliant secret
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* Format: whsec_ + base64(32 random bytes)
|
||||
* @returns A webhook secret in format "whsec_{base64_encoded_random_bytes}"
|
||||
*/
|
||||
export const generateWebhookSecret = (): string => {
|
||||
const secretBytes = randomBytes(32); // 256 bits of entropy
|
||||
return `${WEBHOOK_SECRET_PREFIX}${secretBytes.toString("base64")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a Standard Webhooks secret to get the raw bytes
|
||||
* Strips the whsec_ prefix and base64 decodes the rest
|
||||
*
|
||||
* @param secret The webhook secret (with or without whsec_ prefix)
|
||||
* @returns Buffer containing the raw secret bytes
|
||||
*/
|
||||
export const getWebhookSecretBytes = (secret: string): Buffer => {
|
||||
const base64Part = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
||||
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
||||
: secret;
|
||||
return Buffer.from(base64Part, "base64");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate Standard Webhooks compliant signature
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* @param webhookId Unique message identifier
|
||||
* @param timestamp Unix timestamp in seconds
|
||||
* @param payload The request body as a string
|
||||
* @param secret The shared secret (whsec_ prefixed)
|
||||
* @returns The signature in format "v1,{base64_signature}"
|
||||
*/
|
||||
export const generateStandardWebhookSignature = (
|
||||
webhookId: string,
|
||||
timestamp: number,
|
||||
payload: string,
|
||||
secret: string
|
||||
): string => {
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const secretBytes = getWebhookSecretBytes(secret);
|
||||
const signature = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
return `v1,${signature}`;
|
||||
};
|
||||
|
||||
+6
-145
@@ -130,217 +130,78 @@ export const appLanguages = [
|
||||
code: "en-US",
|
||||
label: {
|
||||
"en-US": "English (US)",
|
||||
"de-DE": "Englisch (US)",
|
||||
"pt-BR": "Inglês (EUA)",
|
||||
"fr-FR": "Anglais (États-Unis)",
|
||||
"zh-Hant-TW": "英文 (美國)",
|
||||
"pt-PT": "Inglês (EUA)",
|
||||
"ro-RO": "Engleză (SUA)",
|
||||
"ja-JP": "英語(米国)",
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
"sv-SE": "Engelska (USA)",
|
||||
"ru-RU": "Английский (США)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "German",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Alemão",
|
||||
"fr-FR": "Allemand",
|
||||
"zh-Hant-TW": "德語",
|
||||
"pt-PT": "Alemão",
|
||||
"ro-RO": "Germană",
|
||||
"ja-JP": "ドイツ語",
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
"sv-SE": "Tyska",
|
||||
"ru-RU": "Немецкий",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
"de-DE": "Portugiesisch (Brasilien)",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"fr-FR": "Portugais (Brésil)",
|
||||
"zh-Hant-TW": "葡萄牙語 (巴西)",
|
||||
"pt-PT": "Português (Brasil)",
|
||||
"ro-RO": "Portugheză (Brazilia)",
|
||||
"ja-JP": "ポルトガル語(ブラジル)",
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
"sv-SE": "Portugisiska (Brasilien)",
|
||||
"ru-RU": "Португальский (Бразилия)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "fr-FR",
|
||||
label: {
|
||||
"en-US": "French",
|
||||
"de-DE": "Französisch",
|
||||
"pt-BR": "Francês",
|
||||
"fr-FR": "Français",
|
||||
"zh-Hant-TW": "法語",
|
||||
"pt-PT": "Francês",
|
||||
"ro-RO": "Franceză",
|
||||
"ja-JP": "フランス語",
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
"sv-SE": "Franska",
|
||||
"ru-RU": "Французский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
"de-DE": "Chinesisch (Traditionell)",
|
||||
"pt-BR": "Chinês (Tradicional)",
|
||||
"fr-FR": "Chinois (Traditionnel)",
|
||||
"zh-Hant-TW": "繁體中文",
|
||||
"pt-PT": "Chinês (Tradicional)",
|
||||
"ro-RO": "Chineza (Tradițională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
"sv-SE": "Kinesiska (traditionell)",
|
||||
"ru-RU": "Китайский (традиционный)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-PT",
|
||||
label: {
|
||||
"en-US": "Portuguese (Portugal)",
|
||||
"de-DE": "Portugiesisch (Portugal)",
|
||||
"pt-BR": "Português (Portugal)",
|
||||
"fr-FR": "Portugais (Portugal)",
|
||||
"zh-Hant-TW": "葡萄牙語 (葡萄牙)",
|
||||
"pt-PT": "Português (Portugal)",
|
||||
"ro-RO": "Portugheză (Portugalia)",
|
||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
"sv-SE": "Portugisiska (Portugal)",
|
||||
"ru-RU": "Португальский (Португалия)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ro-RO",
|
||||
label: {
|
||||
"en-US": "Romanian",
|
||||
"de-DE": "Rumänisch",
|
||||
"pt-BR": "Romeno",
|
||||
"fr-FR": "Roumain",
|
||||
"zh-Hant-TW": "羅馬尼亞語",
|
||||
"pt-PT": "Romeno",
|
||||
"ro-RO": "Română",
|
||||
"ja-JP": "ルーマニア語",
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
"sv-SE": "Rumänska",
|
||||
"ru-RU": "Румынский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
"de-DE": "Japanisch",
|
||||
"pt-BR": "Japonês",
|
||||
"fr-FR": "Japonais",
|
||||
"zh-Hant-TW": "日語",
|
||||
"pt-PT": "Japonês",
|
||||
"ro-RO": "Japoneză",
|
||||
"ja-JP": "日本語",
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
"sv-SE": "Japanska",
|
||||
"ru-RU": "Японский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hans-CN",
|
||||
label: {
|
||||
"en-US": "Chinese (Simplified)",
|
||||
"de-DE": "Chinesisch (Vereinfacht)",
|
||||
"pt-BR": "Chinês (Simplificado)",
|
||||
"fr-FR": "Chinois (Simplifié)",
|
||||
"zh-Hant-TW": "簡體中文",
|
||||
"pt-PT": "Chinês (Simplificado)",
|
||||
"ro-RO": "Chineza (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
"sv-SE": "Kinesiska (förenklad)",
|
||||
"ru-RU": "Китайский (упрощенный)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
"de-DE": "Niederländisch",
|
||||
"pt-BR": "Holandês",
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeza",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
"sv-SE": "Nederländska",
|
||||
"ru-RU": "Голландский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"de-DE": "Spanisch",
|
||||
"pt-BR": "Espanhol",
|
||||
"fr-FR": "Espagnol",
|
||||
"zh-Hant-TW": "西班牙語",
|
||||
"pt-PT": "Espanhol",
|
||||
"ro-RO": "Spaniol",
|
||||
"ja-JP": "スペイン語",
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
"sv-SE": "Spanska",
|
||||
"ru-RU": "Испанский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"de-DE": "Schwedisch",
|
||||
"pt-BR": "Sueco",
|
||||
"fr-FR": "Suédois",
|
||||
"zh-Hant-TW": "瑞典語",
|
||||
"pt-PT": "Sueco",
|
||||
"ro-RO": "Suedeză",
|
||||
"ja-JP": "スウェーデン語",
|
||||
"zh-Hans-CN": "瑞典语",
|
||||
"nl-NL": "Zweeds",
|
||||
"es-ES": "Sueco",
|
||||
"sv-SE": "Svenska",
|
||||
"ru-RU": "Шведский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
];
|
||||
export { iso639Languages };
|
||||
|
||||
@@ -208,6 +208,7 @@ const baseSurveyProperties = {
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
endings: [
|
||||
{
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
|
||||
@@ -308,6 +308,10 @@ describe("Tests for updateSurvey", () => {
|
||||
const updatedSurvey = await updateSurvey(updateSurveyInput);
|
||||
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
|
||||
});
|
||||
|
||||
// Note: Language handling tests (for languages.length > 0 fix) are covered in
|
||||
// apps/web/modules/survey/editor/lib/survey.test.ts where we have better control
|
||||
// over the test mocks. The key fix ensures languages.length > 0 (not > 1) is used.
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
|
||||
@@ -56,6 +56,7 @@ export const selectSurvey = {
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
@@ -328,7 +329,7 @@ export const updateSurveyInternal = async (
|
||||
? currentSurvey.languages.map((l) => l.language.id)
|
||||
: [];
|
||||
const updatedLanguageIds =
|
||||
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
const enabledLanguageIds = languages.map((language) => {
|
||||
if (language.enabled) return language.language.id;
|
||||
});
|
||||
|
||||
@@ -90,11 +90,10 @@ describe("locale", () => {
|
||||
// Verify sv-SE is in AVAILABLE_LOCALES
|
||||
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
||||
|
||||
// Verify Swedish has a language entry with proper labels
|
||||
// Verify Swedish has a language entry with proper label
|
||||
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
||||
expect(swedishLanguage).toBeDefined();
|
||||
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
||||
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
|
||||
|
||||
// Verify the locale can be matched from Accept-Language header
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
|
||||
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
|
||||
"privacy_policy": "Datenschutzerklärung",
|
||||
"product_updates_description": "Monatliche Produktneuigkeiten und Feature-Updates, es gilt die Datenschutzerklärung.",
|
||||
"product_updates_title": "Produkt-Updates",
|
||||
"security_updates_description": "Nur sicherheitsrelevante Informationen, es gilt die Datenschutzerklärung.",
|
||||
"security_updates_title": "Sicherheits-Updates",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"title": "Erstelle dein Formbricks-Konto"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domain",
|
||||
"done": "Fertig",
|
||||
"download": "Herunterladen",
|
||||
"draft": "Entwurf",
|
||||
"duplicate": "Duplikat",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Webhook hinzufügen",
|
||||
"add_webhook_description": "Sende Umfragedaten an einen benutzerdefinierten Endpunkt",
|
||||
"all_current_and_new_surveys": "Alle aktuellen und neuen Umfragen",
|
||||
"copy_secret_now": "Signierungsschlüssel kopieren",
|
||||
"created_by_third_party": "Erstellt von einer dritten Partei",
|
||||
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
|
||||
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
||||
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
||||
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
|
||||
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
|
||||
"please_enter_a_url": "Bitte gib eine URL ein",
|
||||
"response_created": "Antwort erstellt",
|
||||
"response_finished": "Antwort abgeschlossen",
|
||||
"response_updated": "Antwort aktualisiert",
|
||||
"secret_copy_warning": "Bewahren Sie diesen Schlüssel sicher auf. Sie können ihn erneut in den Webhook-Einstellungen einsehen.",
|
||||
"secret_description": "Verwenden Sie diesen Schlüssel, um Webhook-Anfragen zu verifizieren. Siehe Dokumentation zur Signaturverifizierung.",
|
||||
"signing_secret": "Signierungsschlüssel",
|
||||
"source": "Quelle",
|
||||
"test_endpoint": "Test-Endpunkt",
|
||||
"triggers": "Auslöser",
|
||||
"webhook_added_successfully": "Webhook wurde erfolgreich hinzugefügt",
|
||||
"webhook_created": "Webhook erstellt",
|
||||
"webhook_delete_confirmation": "Bist Du sicher, dass Du diesen Webhook löschen möchtest? Dadurch werden dir keine weiteren Benachrichtigungen mehr gesendet.",
|
||||
"webhook_deleted_successfully": "Webhook erfolgreich gelöscht",
|
||||
"webhook_name_placeholder": "Optional: Benenne deinen Webhook zur einfachen Identifizierung",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Logo entfernen",
|
||||
"replace_logo": "Logo ersetzen",
|
||||
"resend_invitation_email": "Einladungsemail erneut senden",
|
||||
"security_list_tip": "Haben Sie sich für unsere Sicherheitsliste angemeldet? Bleiben Sie informiert, um Ihre Instanz sicher zu halten!",
|
||||
"security_list_tip_link": "Hier registrieren.",
|
||||
"share_invite_link": "Einladungslink teilen",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit dein Organisationsmitglied deiner Organisation beitreten kann:",
|
||||
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Zuweisen =",
|
||||
"audience": "Publikum",
|
||||
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
|
||||
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
|
||||
"calculate": "Berechnen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Erfasse eine neue Aktion, um eine Umfrage auszulösen.",
|
||||
"capture_ip_address": "IP-Adresse erfassen",
|
||||
"capture_ip_address_description": "Speichern Sie die IP-Adresse des Befragten in den Antwort-Metadaten zur Duplikaterkennung und für Sicherheitszwecke",
|
||||
"capture_new_action": "Neue Aktion erfassen",
|
||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||
"card_background_color": "Hintergrundfarbe der Karte",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Bitte angeben",
|
||||
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
|
||||
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
|
||||
"progress_saved": "Fortschritt gespeichert",
|
||||
"protect_survey_with_pin": "Umfrage mit einer PIN schützen",
|
||||
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
|
||||
"publish": "Veröffentlichen",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
||||
"first_name": "Vorname",
|
||||
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
||||
"ip_address": "IP-Adresse",
|
||||
"last_name": "Nachname",
|
||||
"not_completed": "Nicht abgeschlossen ⏳",
|
||||
"os": "Betriebssystem",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
|
||||
"please_verify_captcha": "Please verify reCAPTCHA",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"product_updates_description": "Monthly product news and feature updates, Privacy Policy applies.",
|
||||
"product_updates_title": "Product updates",
|
||||
"security_updates_description": "Security relevant information only, Privacy Policy applies.",
|
||||
"security_updates_title": "Security updates",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"title": "Create your Formbricks account"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domain",
|
||||
"done": "Done",
|
||||
"download": "Download",
|
||||
"draft": "Draft",
|
||||
"duplicate": "Duplicate",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Add Webhook",
|
||||
"add_webhook_description": "Send survey response data to a custom endpoint",
|
||||
"all_current_and_new_surveys": "All current and new surveys",
|
||||
"copy_secret_now": "Copy your signing secret",
|
||||
"created_by_third_party": "Created by a Third Party",
|
||||
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
|
||||
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
|
||||
"endpoint_pinged": "Yay! We are able to ping the webhook!",
|
||||
"endpoint_pinged_error": "Unable to ping the webhook!",
|
||||
"learn_to_verify": "Learn how to verify webhook signatures",
|
||||
"please_check_console": "Please check the console for more details",
|
||||
"please_enter_a_url": "Please enter a URL",
|
||||
"response_created": "Response Created",
|
||||
"response_finished": "Response Finished",
|
||||
"response_updated": "Response Updated",
|
||||
"secret_copy_warning": "Store this secret securely. You can view it again in webhook settings.",
|
||||
"secret_description": "Use this secret to verify webhook requests. See documentation for signature verification.",
|
||||
"signing_secret": "Signing Secret",
|
||||
"source": "Source",
|
||||
"test_endpoint": "Test Endpoint",
|
||||
"triggers": "Triggers",
|
||||
"webhook_added_successfully": "Webhook added successfully",
|
||||
"webhook_created": "Webhook Created",
|
||||
"webhook_delete_confirmation": "Are you sure you want to delete this Webhook? This will stop sending you any further notifications.",
|
||||
"webhook_deleted_successfully": "Webhook deleted successfully",
|
||||
"webhook_name_placeholder": "Optional: Label your webhook for easy identification",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Remove logo",
|
||||
"replace_logo": "Replace logo",
|
||||
"resend_invitation_email": "Resend Invitation Email",
|
||||
"security_list_tip": "Are you signed up for our Security List? Stay informed to keep your instance secure!",
|
||||
"security_list_tip_link": "Sign up here.",
|
||||
"share_invite_link": "Share Invite Link",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
|
||||
"test_email_sent_successfully": "Test email sent successfully",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Assign =",
|
||||
"audience": "Audience",
|
||||
"auto_close_on_inactivity": "Auto close on inactivity",
|
||||
"auto_save_disabled": "Auto-save disabled",
|
||||
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
|
||||
"auto_save_on": "Auto-save on",
|
||||
"automatically_close_survey_after": "Automatically close survey after",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com username or username/event",
|
||||
"calculate": "Calculate",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capture a new action to trigger a survey on.",
|
||||
"capture_ip_address": "Capture IP address",
|
||||
"capture_ip_address_description": "Store the respondent's IP address in response metadata for duplicate detection and security purposes",
|
||||
"capture_new_action": "Capture new action",
|
||||
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
||||
"card_background_color": "Card background color",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Please specify",
|
||||
"prevent_double_submission": "Prevent double submission",
|
||||
"prevent_double_submission_description": "Only allow 1 response per email address",
|
||||
"progress_saved": "Progress saved",
|
||||
"protect_survey_with_pin": "Protect survey with a PIN",
|
||||
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
|
||||
"publish": "Publish",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "An error occurred while downloading responses",
|
||||
"first_name": "First Name",
|
||||
"how_to_identify_users": "How to identify users",
|
||||
"ip_address": "IP Address",
|
||||
"last_name": "Last Name",
|
||||
"not_completed": "Not Completed ⏳",
|
||||
"os": "OS",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
|
||||
"privacy_policy": "Política de privacidad",
|
||||
"product_updates_description": "Noticias mensuales del producto y actualizaciones de funciones, se aplica la política de privacidad.",
|
||||
"product_updates_title": "Actualizaciones del producto",
|
||||
"security_updates_description": "Solo información relevante sobre seguridad, se aplica la política de privacidad.",
|
||||
"security_updates_title": "Actualizaciones de seguridad",
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"title": "Crea tu cuenta de Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentación",
|
||||
"documentation": "Documentación",
|
||||
"domain": "Dominio",
|
||||
"done": "Hecho",
|
||||
"download": "Descargar",
|
||||
"draft": "Borrador",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Añadir webhook",
|
||||
"add_webhook_description": "Envía datos de respuestas de encuestas a un endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas las encuestas actuales y nuevas",
|
||||
"copy_secret_now": "Copia tu secreto de firma",
|
||||
"created_by_third_party": "Creado por un tercero",
|
||||
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
|
||||
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
|
||||
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
||||
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
||||
"learn_to_verify": "Aprende a verificar las firmas de webhook",
|
||||
"please_check_console": "Por favor, consulta la consola para más detalles",
|
||||
"please_enter_a_url": "Por favor, introduce una URL",
|
||||
"response_created": "Respuesta creada",
|
||||
"response_finished": "Respuesta finalizada",
|
||||
"response_updated": "Respuesta actualizada",
|
||||
"secret_copy_warning": "Almacena este secreto de forma segura. Puedes verlo de nuevo en la configuración del webhook.",
|
||||
"secret_description": "Usa este secreto para verificar las solicitudes del webhook. Consulta la documentación para la verificación de firma.",
|
||||
"signing_secret": "Secreto de firma",
|
||||
"source": "Origen",
|
||||
"test_endpoint": "Probar endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook añadido correctamente",
|
||||
"webhook_created": "Webhook creado",
|
||||
"webhook_delete_confirmation": "¿Estás seguro de que quieres eliminar este webhook? Esto detendrá el envío de futuras notificaciones.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado correctamente",
|
||||
"webhook_name_placeholder": "Opcional: Etiqueta tu webhook para identificarlo fácilmente",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Eliminar logotipo",
|
||||
"replace_logo": "Reemplazar logotipo",
|
||||
"resend_invitation_email": "Reenviar correo electrónico de invitación",
|
||||
"security_list_tip": "¿Estás suscrito a nuestra lista de seguridad? ¡Mantente informado para mantener tu instancia segura!",
|
||||
"security_list_tip_link": "Regístrate aquí.",
|
||||
"share_invite_link": "Compartir enlace de invitación",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
|
||||
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Asignar =",
|
||||
"audience": "Audiencia",
|
||||
"auto_close_on_inactivity": "Cierre automático por inactividad",
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nombre de usuario de Cal.com o nombre de usuario/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura una nueva acción para activar una encuesta.",
|
||||
"capture_ip_address": "Capturar dirección IP",
|
||||
"capture_ip_address_description": "Almacenar la dirección IP del encuestado en los metadatos de respuesta para la detección de duplicados y fines de seguridad",
|
||||
"capture_new_action": "Capturar nueva acción",
|
||||
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
|
||||
"card_background_color": "Color de fondo de la tarjeta",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Por favor, especifica",
|
||||
"prevent_double_submission": "Evitar envío duplicado",
|
||||
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
|
||||
"progress_saved": "Progreso guardado",
|
||||
"protect_survey_with_pin": "Proteger encuesta con un PIN",
|
||||
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
|
||||
"publish": "Publicar",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Se produjo un error al descargar las respuestas",
|
||||
"first_name": "Nombre",
|
||||
"how_to_identify_users": "Cómo identificar a los usuarios",
|
||||
"ip_address": "Dirección IP",
|
||||
"last_name": "Apellido",
|
||||
"not_completed": "No completado ⏳",
|
||||
"os": "Sistema operativo",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
|
||||
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"product_updates_description": "Actualités mensuelles du produit et mises à jour des fonctionnalités, la politique de confidentialité s'applique.",
|
||||
"product_updates_title": "Mises à jour du produit",
|
||||
"security_updates_description": "Informations relatives à la sécurité uniquement, la politique de confidentialité s'applique.",
|
||||
"security_updates_title": "Mises à jour de sécurité",
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"title": "Créez votre compte Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domaine",
|
||||
"done": "Terminé",
|
||||
"download": "Télécharger",
|
||||
"draft": "Brouillon",
|
||||
"duplicate": "Dupliquer",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Ajouter un Webhook",
|
||||
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
|
||||
"all_current_and_new_surveys": "Tous les sondages actuels et nouveaux",
|
||||
"copy_secret_now": "Copiez votre secret de signature",
|
||||
"created_by_third_party": "Créé par un tiers",
|
||||
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
|
||||
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
|
||||
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
||||
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
||||
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
|
||||
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
|
||||
"please_enter_a_url": "Veuillez entrer une URL.",
|
||||
"response_created": "Réponse créée",
|
||||
"response_finished": "Réponse terminée",
|
||||
"response_updated": "Réponse mise à jour",
|
||||
"secret_copy_warning": "Conservez ce secret en lieu sûr. Vous pourrez le consulter à nouveau dans les paramètres du webhook.",
|
||||
"secret_description": "Utilisez ce secret pour vérifier les requêtes webhook. Consultez la documentation pour la vérification de signature.",
|
||||
"signing_secret": "Secret de signature",
|
||||
"source": "Source",
|
||||
"test_endpoint": "Point de test",
|
||||
"triggers": "Déclencheurs",
|
||||
"webhook_added_successfully": "Webhook ajouté avec succès",
|
||||
"webhook_created": "Webhook créé",
|
||||
"webhook_delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce Webhook ? Cela arrêtera l'envoi de toute notification future.",
|
||||
"webhook_deleted_successfully": "Webhook supprimé avec succès",
|
||||
"webhook_name_placeholder": "Optionnel : Étiquetez votre webhook pour une identification facile",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Supprimer le logo",
|
||||
"replace_logo": "Remplacer le logo",
|
||||
"resend_invitation_email": "Renvoyer l'e-mail d'invitation",
|
||||
"security_list_tip": "Êtes-vous inscrit à notre liste de sécurité ? Restez informé pour maintenir votre instance sécurisée !",
|
||||
"security_list_tip_link": "Inscrivez-vous ici.",
|
||||
"share_invite_link": "Partager le lien d'invitation",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
|
||||
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Attribuer =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
|
||||
"calculate": "Calculer",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturez une nouvelle action pour déclencher une enquête.",
|
||||
"capture_ip_address": "Capturer l'adresse IP",
|
||||
"capture_ip_address_description": "Stocker l'adresse IP du répondant dans les métadonnées de réponse à des fins de détection des doublons et de sécurité",
|
||||
"capture_new_action": "Capturer une nouvelle action",
|
||||
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
||||
"card_background_color": "Couleur de fond de la carte",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Veuillez préciser",
|
||||
"prevent_double_submission": "Empêcher la double soumission",
|
||||
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
|
||||
"progress_saved": "Progression enregistrée",
|
||||
"protect_survey_with_pin": "Protéger l'enquête par un code PIN",
|
||||
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
|
||||
"publish": "Publier",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
||||
"first_name": "Prénom",
|
||||
"how_to_identify_users": "Comment identifier les utilisateurs",
|
||||
"ip_address": "Adresse IP",
|
||||
"last_name": "Nom de famille",
|
||||
"not_completed": "Non terminé ⏳",
|
||||
"os": "Système d'exploitation",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
|
||||
"please_verify_captcha": "reCAPTCHAを認証してください",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"product_updates_description": "毎月の製品ニュースと機能アップデート、プライバシーポリシーが適用されます。",
|
||||
"product_updates_title": "製品アップデート",
|
||||
"security_updates_description": "セキュリティ関連情報のみ、プライバシーポリシーが適用されます。",
|
||||
"security_updates_title": "セキュリティアップデート",
|
||||
"terms_of_service": "利用規約",
|
||||
"title": "Formbricksアカウントを作成"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "ドキュメント",
|
||||
"documentation": "ドキュメント",
|
||||
"domain": "ドメイン",
|
||||
"done": "完了",
|
||||
"download": "ダウンロード",
|
||||
"draft": "下書き",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Webhook を追加",
|
||||
"add_webhook_description": "フォーム回答データを任意のエンドポイントへ送信",
|
||||
"all_current_and_new_surveys": "現在および新規のすべてのフォーム",
|
||||
"copy_secret_now": "署名シークレットをコピー",
|
||||
"created_by_third_party": "サードパーティによって作成",
|
||||
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
|
||||
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
|
||||
"endpoint_pinged": "成功!Webhook に ping できました。",
|
||||
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
||||
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
|
||||
"please_check_console": "詳細はコンソールを確認してください",
|
||||
"please_enter_a_url": "URL を入力してください",
|
||||
"response_created": "回答作成",
|
||||
"response_finished": "回答完了",
|
||||
"response_updated": "回答更新",
|
||||
"secret_copy_warning": "このシークレットを安全に保管してください。Webhook 設定で再度確認できます。",
|
||||
"secret_description": "このシークレットを使用して Webhook リクエストを検証します。署名検証についてはドキュメントを参照してください。",
|
||||
"signing_secret": "署名シークレット",
|
||||
"source": "ソース",
|
||||
"test_endpoint": "エンドポイントをテスト",
|
||||
"triggers": "トリガー",
|
||||
"webhook_added_successfully": "Webhook を追加しました",
|
||||
"webhook_created": "Webhook を作成しました",
|
||||
"webhook_delete_confirmation": "このWebhookを削除してもよろしいですか?以後の通知は送信されません。",
|
||||
"webhook_deleted_successfully": "Webhook を削除しました",
|
||||
"webhook_name_placeholder": "任意: 識別しやすいようWebhookにラベルを付ける",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "ロゴを削除",
|
||||
"replace_logo": "ロゴを交換",
|
||||
"resend_invitation_email": "招待メールを再送信",
|
||||
"security_list_tip": "セキュリティリストに登録していますか?インスタンスを安全に保つために最新情報を入手しましょう!",
|
||||
"security_list_tip_link": "こちらからサインアップしてください。",
|
||||
"share_invite_link": "招待リンクを共有",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "このリンクを共有して、組織メンバーを招待できます:",
|
||||
"test_email_sent_successfully": "テストメールを正常に送信しました",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "割り当て =",
|
||||
"audience": "オーディエンス",
|
||||
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
"automatically_close_survey_after": "フォームを自動的に閉じる",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "フォームをトリガーする新しいアクションをキャプチャします。",
|
||||
"capture_ip_address": "IPアドレスを記録",
|
||||
"capture_ip_address_description": "重複検出とセキュリティ目的で、回答者のIPアドレスを回答メタデータに保存します",
|
||||
"capture_new_action": "新しいアクションをキャプチャ",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
|
||||
"card_background_color": "カードの背景色",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "具体的に指定してください",
|
||||
"prevent_double_submission": "二重送信を防ぐ",
|
||||
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
|
||||
"progress_saved": "進捗を保存しました",
|
||||
"protect_survey_with_pin": "PINでフォームを保護",
|
||||
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
|
||||
"publish": "公開",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
|
||||
"first_name": "名",
|
||||
"how_to_identify_users": "ユーザーを識別する方法",
|
||||
"ip_address": "IPアドレス",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完了 ⏳",
|
||||
"os": "OS",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
|
||||
"please_verify_captcha": "Controleer reCAPTCHA",
|
||||
"privacy_policy": "Privacybeleid",
|
||||
"product_updates_description": "Maandelijks productnieuws en feature-updates, privacybeleid is van toepassing.",
|
||||
"product_updates_title": "Product-updates",
|
||||
"security_updates_description": "Alleen beveiligingsrelevante informatie, privacybeleid is van toepassing.",
|
||||
"security_updates_title": "Beveiligingsupdates",
|
||||
"terms_of_service": "Servicevoorwaarden",
|
||||
"title": "Maak uw Formbricks-account aan"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentatie",
|
||||
"documentation": "Documentatie",
|
||||
"domain": "Domein",
|
||||
"done": "Klaar",
|
||||
"download": "Downloaden",
|
||||
"draft": "Voorlopige versie",
|
||||
"duplicate": "Duplicaat",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Webhook toevoegen",
|
||||
"add_webhook_description": "Stuur enquêtereactiegegevens naar een aangepast eindpunt",
|
||||
"all_current_and_new_surveys": "Alle huidige en nieuwe onderzoeken",
|
||||
"copy_secret_now": "Kopieer je ondertekeningsgeheim",
|
||||
"created_by_third_party": "Gemaakt door een derde partij",
|
||||
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
|
||||
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
|
||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||
"please_check_console": "Controleer de console voor meer details",
|
||||
"please_enter_a_url": "Voer een URL in",
|
||||
"response_created": "Reactie gemaakt",
|
||||
"response_finished": "Reactie voltooid",
|
||||
"response_updated": "Reactie bijgewerkt",
|
||||
"secret_copy_warning": "Bewaar dit geheim veilig. Je kunt het opnieuw bekijken in de webhook-instellingen.",
|
||||
"secret_description": "Gebruik dit geheim om webhook-verzoeken te verifiëren. Zie de documentatie voor handtekeningverificatie.",
|
||||
"signing_secret": "Ondertekeningsgeheim",
|
||||
"source": "Bron",
|
||||
"test_endpoint": "Eindpunt testen",
|
||||
"triggers": "Triggers",
|
||||
"webhook_added_successfully": "Webhook succesvol toegevoegd",
|
||||
"webhook_created": "Webhook aangemaakt",
|
||||
"webhook_delete_confirmation": "Weet u zeker dat u deze webhook wilt verwijderen? Hierdoor worden er geen verdere meldingen meer verzonden.",
|
||||
"webhook_deleted_successfully": "Webhook is succesvol verwijderd",
|
||||
"webhook_name_placeholder": "Optioneel: Label uw webhook voor gemakkelijke identificatie",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Logo verwijderen",
|
||||
"replace_logo": "Logo vervangen",
|
||||
"resend_invitation_email": "Uitnodigings-e-mail opnieuw verzenden",
|
||||
"security_list_tip": "Ben je aangemeld voor onze beveiligingslijst? Blijf op de hoogte om je instantie veilig te houden!",
|
||||
"security_list_tip_link": "Meld je hier aan.",
|
||||
"share_invite_link": "Deel de uitnodigingslink",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Deel deze link om uw organisatielid lid te laten worden van uw organisatie:",
|
||||
"test_email_sent_successfully": "Test-e-mail succesvol verzonden",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Toewijzen =",
|
||||
"audience": "Publiek",
|
||||
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com-gebruikersnaam of gebruikersnaam/evenement",
|
||||
"calculate": "Berekenen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Leg een nieuwe actie vast om een enquête over te activeren.",
|
||||
"capture_ip_address": "IP-adres vastleggen",
|
||||
"capture_ip_address_description": "Sla het IP-adres van de respondent op in de metadata van het antwoord voor detectie van duplicaten en beveiligingsdoeleinden",
|
||||
"capture_new_action": "Leg nieuwe actie vast",
|
||||
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
|
||||
"card_background_color": "Achtergrondkleur van de kaart",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Gelieve te specificeren",
|
||||
"prevent_double_submission": "Voorkom dubbele indiening",
|
||||
"prevent_double_submission_description": "Er is slechts 1 reactie per e-mailadres toegestaan",
|
||||
"progress_saved": "Voortgang opgeslagen",
|
||||
"protect_survey_with_pin": "Beveilig onderzoek met een pincode",
|
||||
"protect_survey_with_pin_description": "Alleen gebruikers die de pincode hebben, hebben toegang tot de enquête.",
|
||||
"publish": "Publiceren",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Er is een fout opgetreden bij het downloaden van de antwoorden",
|
||||
"first_name": "Voornaam",
|
||||
"how_to_identify_users": "Hoe gebruikers te identificeren",
|
||||
"ip_address": "IP-adres",
|
||||
"last_name": "Achternaam",
|
||||
"not_completed": "Niet voltooid ⏳",
|
||||
"os": "Besturingssysteem",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"product_updates_description": "Novidades mensais do produto e atualizações de recursos, a Política de Privacidade se aplica.",
|
||||
"product_updates_title": "Atualizações do produto",
|
||||
"security_updates_description": "Apenas informações relevantes sobre segurança, a Política de Privacidade se aplica.",
|
||||
"security_updates_title": "Atualizações de segurança",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"title": "Crie sua conta no Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "baixar",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados das respostas da pesquisa para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas as pesquisas atuais e novas",
|
||||
"copy_secret_now": "Copie seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
|
||||
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
|
||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira uma URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Finalizada",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo com segurança. Você pode visualizá-lo novamente nas configurações do webhook.",
|
||||
"secret_description": "Use este segredo para verificar requisições de webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "fonte",
|
||||
"test_endpoint": "Testar Ponto de Extremidade",
|
||||
"triggers": "gatilhos",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem certeza de que quer deletar esse Webhook? Isso vai parar de te enviar qualquer notificação.",
|
||||
"webhook_deleted_successfully": "Webhook deletado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Dê um nome ao seu webhook para facilitar a identificação",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Remover logo",
|
||||
"replace_logo": "Substituir logo",
|
||||
"resend_invitation_email": "Reenviar E-mail de Convite",
|
||||
"security_list_tip": "Você está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter sua instância segura!",
|
||||
"security_list_tip_link": "Cadastre-se aqui.",
|
||||
"share_invite_link": "Compartilhar Link de Convite",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Compartilhe esse link para que o membro da sua organização possa entrar na sua organização:",
|
||||
"test_email_sent_successfully": "E-mail de teste enviado com sucesso",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura uma nova ação pra disparar uma pesquisa.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do respondente nos metadados da resposta para fins de detecção de duplicatas e segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Evitar envio duplicado",
|
||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||
"progress_saved": "Progresso salvo",
|
||||
"protect_survey_with_pin": "Proteger pesquisa com um PIN",
|
||||
"protect_survey_with_pin_description": "Somente usuários que têm o PIN podem acessar a pesquisa.",
|
||||
"publish": "Publicar",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao baixar as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar usuários",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Sobrenome",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "sistema operacional",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"product_updates_description": "Notícias mensais sobre o produto e atualizações de funcionalidades, aplica-se a Política de Privacidade.",
|
||||
"product_updates_title": "Atualizações do produto",
|
||||
"security_updates_description": "Apenas informações relevantes sobre segurança, aplica-se a Política de Privacidade.",
|
||||
"security_updates_title": "Atualizações de segurança",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"title": "Crie a sua conta Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "Transferir",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados de resposta do inquérito para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todos os inquéritos atuais e novos",
|
||||
"copy_secret_now": "Copiar o seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
|
||||
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
|
||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira um URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Concluída",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo de forma segura. Pode visualizá-lo novamente nas definições do webhook.",
|
||||
"secret_description": "Use este segredo para verificar os pedidos do webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "Fonte",
|
||||
"test_endpoint": "Testar Endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem a certeza de que deseja eliminar este Webhook? Isto irá parar de lhe enviar quaisquer notificações futuras.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Rotule o seu webhook para fácil identificação",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Remover logótipo",
|
||||
"replace_logo": "Substituir logotipo",
|
||||
"resend_invitation_email": "Reenviar Email de Convite",
|
||||
"security_list_tip": "Está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter a sua instância segura!",
|
||||
"security_list_tip_link": "Inscreva-se aqui.",
|
||||
"share_invite_link": "Partilhar Link de Convite",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partilhe este link para permitir que o membro da sua organização se junte à sua organização:",
|
||||
"test_email_sent_successfully": "Email de teste enviado com sucesso",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturar uma nova ação para desencadear um inquérito.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do inquirido nos metadados da resposta para deteção de duplicados e fins de segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Impedir submissão dupla",
|
||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||
"progress_saved": "Progresso guardado",
|
||||
"protect_survey_with_pin": "Proteger inquérito com um PIN",
|
||||
"protect_survey_with_pin_description": "Apenas utilizadores com o PIN podem aceder ao inquérito.",
|
||||
"publish": "Publicar",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao transferir as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar utilizadores",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Apelido",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "SO",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
|
||||
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
|
||||
"privacy_policy": "Politica de confidențialitate",
|
||||
"product_updates_description": "Noutăți lunare despre produse și actualizări de funcționalități; se aplică Politica de confidențialitate.",
|
||||
"product_updates_title": "Actualizări de produs",
|
||||
"security_updates_description": "Doar informații relevante pentru securitate; se aplică Politica de confidențialitate.",
|
||||
"security_updates_title": "Actualizări de securitate",
|
||||
"terms_of_service": "Termeni de utilizare a serviciului",
|
||||
"title": "Creați-vă contul Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentație",
|
||||
"documentation": "Documentație",
|
||||
"domain": "Domeniu",
|
||||
"done": "Gata",
|
||||
"download": "Descărcare",
|
||||
"draft": "Schiță",
|
||||
"duplicate": "Duplicități",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Adaugă Webhook",
|
||||
"add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat",
|
||||
"all_current_and_new_surveys": "Toate chestionarele curente și noi",
|
||||
"copy_secret_now": "Copiază secretul de semnare",
|
||||
"created_by_third_party": "Creat de o Parte Terță",
|
||||
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
|
||||
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
||||
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
||||
"response_created": "Răspuns creat",
|
||||
"response_finished": "Răspuns finalizat",
|
||||
"response_updated": "Răspuns actualizat",
|
||||
"secret_copy_warning": "Păstrează acest secret în siguranță. Îl poți vizualiza din nou în setările webhook-ului.",
|
||||
"secret_description": "Folosește acest secret pentru a verifica cererile webhook. Vezi documentația pentru verificarea semnăturii.",
|
||||
"signing_secret": "Secret de semnare",
|
||||
"source": "Sursă",
|
||||
"test_endpoint": "Punct final de test",
|
||||
"triggers": "Declanșatori",
|
||||
"webhook_added_successfully": "Webhook adăugat cu succes",
|
||||
"webhook_created": "Webhook creat",
|
||||
"webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.",
|
||||
"webhook_deleted_successfully": "Webhook șters cu succes",
|
||||
"webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Înlătură siglă",
|
||||
"replace_logo": "Înlocuiește sigla",
|
||||
"resend_invitation_email": "Retrimite emailul de invitație",
|
||||
"security_list_tip": "Ești abonat la lista noastră de securitate? Rămâi informat pentru a-ți menține instanța în siguranță!",
|
||||
"security_list_tip_link": "Înscrie-te aici.",
|
||||
"share_invite_link": "Distribuie link-ul de invitație",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
|
||||
"test_email_sent_successfully": "Email de test trimis cu succes",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Atribuire =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Închidere automată la inactivitate",
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
"automatically_close_survey_after": "Închideți automat sondajul după",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
|
||||
"calculate": "Calculați",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.",
|
||||
"capture_ip_address": "Capturare adresă IP",
|
||||
"capture_ip_address_description": "Stochează adresa IP a respondentului în metadatele răspunsului pentru detectarea duplicatelor și în scopuri de securitate",
|
||||
"capture_new_action": "Capturați acțiune nouă",
|
||||
"card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}",
|
||||
"card_background_color": "Culoarea de fundal a cardului",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Vă rugăm să specificați",
|
||||
"prevent_double_submission": "Prevenire trimitere dublă",
|
||||
"prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.",
|
||||
"progress_saved": "Progres salvat",
|
||||
"protect_survey_with_pin": "Protejați sondajul cu un PIN",
|
||||
"protect_survey_with_pin_description": "Doar utilizatorii care cunosc PIN-ul pot accesa sondajul.",
|
||||
"publish": "Publică",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor",
|
||||
"first_name": "Prenume",
|
||||
"how_to_identify_users": "Cum să identifici utilizatorii",
|
||||
"ip_address": "Adresă IP",
|
||||
"last_name": "Nume de familie",
|
||||
"not_completed": "Necompletat ⏳",
|
||||
"os": "SO",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
|
||||
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
|
||||
"privacy_policy": "Политика конфиденциальности",
|
||||
"product_updates_description": "Ежемесячные новости о продукте и обновления функций. Применяется Политика конфиденциальности.",
|
||||
"product_updates_title": "Обновления продукта",
|
||||
"security_updates_description": "Только важная информация по безопасности. Применяется Политика конфиденциальности.",
|
||||
"security_updates_title": "Обновления безопасности",
|
||||
"terms_of_service": "Условия использования",
|
||||
"title": "Создайте аккаунт Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Документация",
|
||||
"documentation": "Документация",
|
||||
"domain": "Домен",
|
||||
"done": "Готово",
|
||||
"download": "Скачать",
|
||||
"draft": "Черновик",
|
||||
"duplicate": "Дублировать",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Добавить webhook",
|
||||
"add_webhook_description": "Отправляйте данные ответов на опрос на пользовательский endpoint",
|
||||
"all_current_and_new_surveys": "Все текущие и новые опросы",
|
||||
"copy_secret_now": "Скопируйте ваш секрет подписи",
|
||||
"created_by_third_party": "Создано сторонней организацией",
|
||||
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
||||
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
||||
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||
"response_created": "Ответ создан",
|
||||
"response_finished": "Ответ завершён",
|
||||
"response_updated": "Ответ обновлён",
|
||||
"secret_copy_warning": "Храните этот секрет в надёжном месте. Вы сможете просмотреть его снова в настройках webhook.",
|
||||
"secret_description": "Используйте этот секрет для проверки запросов webhook. Подробнее о проверке подписи — в документации.",
|
||||
"signing_secret": "Секрет подписи",
|
||||
"source": "Источник",
|
||||
"test_endpoint": "Тестировать endpoint",
|
||||
"triggers": "Триггеры",
|
||||
"webhook_added_successfully": "Webhook успешно добавлен",
|
||||
"webhook_created": "Webhook создан",
|
||||
"webhook_delete_confirmation": "Вы уверены, что хотите удалить этот webhook? Это прекратит отправку вам любых дальнейших уведомлений.",
|
||||
"webhook_deleted_successfully": "Webhook успешно удалён",
|
||||
"webhook_name_placeholder": "Необязательно: дайте метку вашему webhook для удобной идентификации",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Удалить логотип",
|
||||
"replace_logo": "Заменить логотип",
|
||||
"resend_invitation_email": "Отправить приглашение повторно",
|
||||
"security_list_tip": "Вы подписаны на нашу рассылку по безопасности? Будьте в курсе, чтобы обезопасить свой экземпляр!",
|
||||
"security_list_tip_link": "Зарегистрируйтесь здесь.",
|
||||
"share_invite_link": "Поделиться ссылкой-приглашением",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Поделитесь этой ссылкой, чтобы участник вашей организации мог присоединиться к ней:",
|
||||
"test_email_sent_successfully": "Тестовое письмо успешно отправлено",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Назначить =",
|
||||
"audience": "Аудитория",
|
||||
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
"automatically_close_survey_after": "Автоматически закрыть опрос через",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Имя пользователя Cal.com или username/event",
|
||||
"calculate": "Вычислить",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Захватить новое действие для запуска опроса.",
|
||||
"capture_ip_address": "Сохранять IP-адрес",
|
||||
"capture_ip_address_description": "Сохранять IP-адрес респондента в метаданных ответа для обнаружения дубликатов и обеспечения безопасности",
|
||||
"capture_new_action": "Захватить новое действие",
|
||||
"card_arrangement_for_survey_type_derived": "Расположение карточек для опросов типа {surveyTypeDerived}",
|
||||
"card_background_color": "Цвет фона карточки",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Пожалуйста, уточните",
|
||||
"prevent_double_submission": "Предотвратить повторную отправку",
|
||||
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
|
||||
"progress_saved": "Прогресс сохранён",
|
||||
"protect_survey_with_pin": "Защитить опрос с помощью PIN-кода",
|
||||
"protect_survey_with_pin_description": "Только пользователи, у которых есть PIN-код, могут получить доступ к опросу.",
|
||||
"publish": "Опубликовать",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
|
||||
"first_name": "Имя",
|
||||
"how_to_identify_users": "Как идентифицировать пользователей",
|
||||
"ip_address": "IP-адрес",
|
||||
"last_name": "Фамилия",
|
||||
"not_completed": "Не завершено ⏳",
|
||||
"os": "ОС",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
|
||||
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
|
||||
"privacy_policy": "Integritetspolicy",
|
||||
"product_updates_description": "Månatliga produktnyheter och funktionsuppdateringar. Integritetspolicyn gäller.",
|
||||
"product_updates_title": "Produktuppdateringar",
|
||||
"security_updates_description": "Endast säkerhetsrelaterad information. Integritetspolicyn gäller.",
|
||||
"security_updates_title": "Säkerhetsuppdateringar",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"title": "Skapa ditt Formbricks-konto"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domän",
|
||||
"done": "Klar",
|
||||
"download": "Ladda ner",
|
||||
"draft": "Utkast",
|
||||
"duplicate": "Duplicera",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Lägg till webhook",
|
||||
"add_webhook_description": "Skicka enkätsvardata till en anpassad endpoint",
|
||||
"all_current_and_new_surveys": "Alla nuvarande och nya enkäter",
|
||||
"copy_secret_now": "Kopiera din signeringsnyckel",
|
||||
"created_by_third_party": "Skapad av tredje part",
|
||||
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
|
||||
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
|
||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||
"please_enter_a_url": "Vänligen ange en URL",
|
||||
"response_created": "Svar skapat",
|
||||
"response_finished": "Svar slutfört",
|
||||
"response_updated": "Svar uppdaterat",
|
||||
"secret_copy_warning": "Förvara denna nyckel säkert. Du kan visa den igen i webhook-inställningarna.",
|
||||
"secret_description": "Använd denna nyckel för att verifiera webhook-förfrågningar. Se dokumentationen för signaturverifiering.",
|
||||
"signing_secret": "Signeringsnyckel",
|
||||
"source": "Källa",
|
||||
"test_endpoint": "Testa endpoint",
|
||||
"triggers": "Utlösare",
|
||||
"webhook_added_successfully": "Webhook tillagd",
|
||||
"webhook_created": "Webhook skapad",
|
||||
"webhook_delete_confirmation": "Är du säker på att du vill ta bort denna webhook? Detta kommer att stoppa alla ytterligare notifieringar.",
|
||||
"webhook_deleted_successfully": "Webhook borttagen",
|
||||
"webhook_name_placeholder": "Valfritt: Namnge din webhook för enkel identifiering",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Ta bort logotyp",
|
||||
"replace_logo": "Ersätt logotyp",
|
||||
"resend_invitation_email": "Skicka inbjudningsmejl igen",
|
||||
"security_list_tip": "Är du med på vår säkerhetslista? Håll dig informerad för att skydda din instans!",
|
||||
"security_list_tip_link": "Registrera dig här.",
|
||||
"share_invite_link": "Dela inbjudningslänk",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Dela denna länk för att låta din organisationsmedlem gå med i din organisation:",
|
||||
"test_email_sent_successfully": "Test-e-post skickat",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Tilldela =",
|
||||
"audience": "Målgrupp",
|
||||
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com-användarnamn eller användarnamn/händelse",
|
||||
"calculate": "Beräkna",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Fånga en ny åtgärd att utlösa en enkät på.",
|
||||
"capture_ip_address": "Registrera IP-adress",
|
||||
"capture_ip_address_description": "Spara respondentens IP-adress i svarsmetadatan för att upptäcka dubbletter och av säkerhetsskäl",
|
||||
"capture_new_action": "Fånga ny åtgärd",
|
||||
"card_arrangement_for_survey_type_derived": "Kortarrangemang för {surveyTypeDerived}-enkäter",
|
||||
"card_background_color": "Kortets bakgrundsfärg",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Vänligen specificera",
|
||||
"prevent_double_submission": "Förhindra dubbelinskickning",
|
||||
"prevent_double_submission_description": "Tillåt endast 1 svar per e-postadress",
|
||||
"progress_saved": "Framsteg sparade",
|
||||
"protect_survey_with_pin": "Skydda enkäten med en PIN",
|
||||
"protect_survey_with_pin_description": "Endast användare som har PIN-koden kan komma åt enkäten.",
|
||||
"publish": "Publicera",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Ett fel uppstod vid nedladdning av svar",
|
||||
"first_name": "Förnamn",
|
||||
"how_to_identify_users": "Hur man identifierar användare",
|
||||
"ip_address": "IP-adress",
|
||||
"last_name": "Efternamn",
|
||||
"not_completed": "Inte slutförd ⏳",
|
||||
"os": "OS",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "大小写混合",
|
||||
"please_verify_captcha": "请 验证 reCAPTCHA",
|
||||
"privacy_policy": "隐私政策",
|
||||
"product_updates_description": "每月产品新闻和功能更新,适用隐私政策。",
|
||||
"product_updates_title": "产品更新",
|
||||
"security_updates_description": "仅限安全相关信息,适用隐私政策。",
|
||||
"security_updates_title": "安全更新",
|
||||
"terms_of_service": "服务条款",
|
||||
"title": "创建你的 Formbricks 账户"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "文档",
|
||||
"documentation": "文档",
|
||||
"domain": "域名",
|
||||
"done": "完成",
|
||||
"download": "下载",
|
||||
"draft": "草稿",
|
||||
"duplicate": "复制",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "添加 Webhook",
|
||||
"add_webhook_description": "发送 调查 响应 数据 到 自定义 端点",
|
||||
"all_current_and_new_surveys": "所有 当前 和 新的 调查",
|
||||
"copy_secret_now": "复制您的签名密钥",
|
||||
"created_by_third_party": "由 第三方 创建",
|
||||
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
||||
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
||||
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||
"please_check_console": "请查看控制台以获取更多详情",
|
||||
"please_enter_a_url": "请输入一个 URL",
|
||||
"response_created": "创建 响应",
|
||||
"response_finished": "响应 完成",
|
||||
"response_updated": "更新 响应",
|
||||
"secret_copy_warning": "请妥善保存此密钥。您可以在 Webhook 设置中再次查看。",
|
||||
"secret_description": "使用此密钥验证 Webhook 请求。有关签名验证,请参阅文档。",
|
||||
"signing_secret": "签名密钥",
|
||||
"source": "来源",
|
||||
"test_endpoint": "测试 端点",
|
||||
"triggers": "触发器",
|
||||
"webhook_added_successfully": "Webhook 添加成功",
|
||||
"webhook_created": "Webhook 已创建",
|
||||
"webhook_delete_confirmation": "您 确定 要 删除 此 Webhook 吗?这 将 停止 向 您 发送 更多 通知 。",
|
||||
"webhook_deleted_successfully": "Webhook 删除 成功",
|
||||
"webhook_name_placeholder": "可选 : 为 您的 Webhook 标注 标签 以 便于 识别",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "移除 logo",
|
||||
"replace_logo": "替换 logo",
|
||||
"resend_invitation_email": "重新发送邀请邮件",
|
||||
"security_list_tip": "您已订阅我们的安全列表了吗?保持关注,保障您的实例安全!",
|
||||
"security_list_tip_link": "点击此处注册。",
|
||||
"share_invite_link": "分享邀请链接",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享 这个 链接 以 让 你的 组织 成员 加入 你的 组织:",
|
||||
"test_email_sent_successfully": "测试 邮件 发送 成功",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "指派 =",
|
||||
"audience": "受众",
|
||||
"auto_close_on_inactivity": "自动关闭 在 无活动时",
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
"automatically_close_survey_after": "自动 关闭 调查 后",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com 用户名 或 用户名/事件",
|
||||
"calculate": "计算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "捕获一个新动作以触发调查。",
|
||||
"capture_ip_address": "记录IP地址",
|
||||
"capture_ip_address_description": "将答题者的IP地址存储在响应元数据中,用于重复检测和安全目的",
|
||||
"capture_new_action": "捕获 新动作",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
|
||||
"card_background_color": "卡片 的 背景 颜色",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "请 指定",
|
||||
"prevent_double_submission": "防止 重复 提交",
|
||||
"prevent_double_submission_description": "只允许每个 email 地址提供 1 个回复",
|
||||
"progress_saved": "进度已保存",
|
||||
"protect_survey_with_pin": "使用 PIN 保护 调查",
|
||||
"protect_survey_with_pin_description": "只有 拥有 PIN 的 用户 可以 访问 调查。",
|
||||
"publish": "发布",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "下载答复时发生错误",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何 识别 用户",
|
||||
"ip_address": "IP地址",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "操作系统",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
|
||||
"please_verify_captcha": "請驗證 reCAPTCHA",
|
||||
"privacy_policy": "隱私權政策",
|
||||
"product_updates_description": "每月產品新聞與功能更新,適用隱私權政策。",
|
||||
"product_updates_title": "產品更新",
|
||||
"security_updates_description": "僅限安全相關資訊,適用隱私權政策。",
|
||||
"security_updates_title": "安全更新",
|
||||
"terms_of_service": "服務條款",
|
||||
"title": "建立您的 Formbricks 帳戶"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "文件",
|
||||
"documentation": "文件",
|
||||
"domain": "網域",
|
||||
"done": "完成",
|
||||
"download": "下載",
|
||||
"draft": "草稿",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "新增 Webhook",
|
||||
"add_webhook_description": "將問卷回應資料傳送至自訂端點",
|
||||
"all_current_and_new_surveys": "所有目前和新的問卷",
|
||||
"copy_secret_now": "複製您的簽章密鑰",
|
||||
"created_by_third_party": "由第三方建立",
|
||||
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
||||
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
|
||||
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||
"please_enter_a_url": "請輸入網址",
|
||||
"response_created": "已建立回應",
|
||||
"response_finished": "已完成回應",
|
||||
"response_updated": "已更新回應",
|
||||
"secret_copy_warning": "請妥善保存此密鑰。您可以在 Webhook 設定中再次查看。",
|
||||
"secret_description": "使用此密鑰來驗證 Webhook 請求。請參閱文件以了解簽章驗證方式。",
|
||||
"signing_secret": "簽章密鑰",
|
||||
"source": "來源",
|
||||
"test_endpoint": "測試端點",
|
||||
"triggers": "觸發器",
|
||||
"webhook_added_successfully": "Webhook 已成功新增",
|
||||
"webhook_created": "Webhook 已建立",
|
||||
"webhook_delete_confirmation": "您確定要刪除此 Webhook 嗎?這將停止向您發送任何進一步的通知。",
|
||||
"webhook_deleted_successfully": "Webhook 已成功刪除",
|
||||
"webhook_name_placeholder": "選填:為您的 Webhook 加上標籤以便於識別",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "移除標誌",
|
||||
"replace_logo": "取代標誌",
|
||||
"resend_invitation_email": "重新發送邀請電子郵件",
|
||||
"security_list_tip": "您已訂閱我們的安全名單了嗎?保持關注,確保您的實例安全!",
|
||||
"security_list_tip_link": "請在此註冊。",
|
||||
"share_invite_link": "分享邀請連結",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享此連結以讓您的組織成員加入您的組織:",
|
||||
"test_email_sent_successfully": "測試電子郵件已成功發送",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "等於 =",
|
||||
"audience": "受眾",
|
||||
"auto_close_on_inactivity": "非活動時自動關閉",
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
"automatically_close_survey_after": "在指定時間自動關閉問卷",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "擷取新的操作以觸發問卷。",
|
||||
"capture_ip_address": "擷取 IP 位址",
|
||||
"capture_ip_address_description": "將受訪者的 IP 位址儲存在回應中繼資料中,以便進行重複檢測與安全性用途",
|
||||
"capture_new_action": "擷取新操作",
|
||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||
"card_background_color": "卡片背景顏色",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "請指定",
|
||||
"prevent_double_submission": "防止重複提交",
|
||||
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
|
||||
"progress_saved": "進度已儲存",
|
||||
"protect_survey_with_pin": "使用 PIN 碼保護問卷",
|
||||
"protect_survey_with_pin_description": "只有擁有 PIN 碼的使用者才能存取問卷。",
|
||||
"publish": "發布",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "下載回應時發生錯誤",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何識別使用者",
|
||||
"ip_address": "IP 位址",
|
||||
"last_name": "姓氏",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "作業系統",
|
||||
|
||||
+5
@@ -123,6 +123,11 @@ export const SingleResponseCardMetadata = ({ response, locale }: SingleResponseC
|
||||
{t("environments.surveys.responses.country")}: {response.meta.country}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.ipAddress && (
|
||||
<p className="truncate" title={`IP Address: ${response.meta.ipAddress}`}>
|
||||
{t("environments.surveys.responses.ip_address")}: {response.meta.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildCommonFilterQuery } from "./utils";
|
||||
|
||||
describe("buildCommonFilterQuery", () => {
|
||||
// Test for line 32: spread existing date filter when adding startDate
|
||||
it("should preserve existing date filter when adding startDate", () => {
|
||||
test("should preserve existing date filter when adding startDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
@@ -23,7 +23,7 @@ describe("buildCommonFilterQuery", () => {
|
||||
});
|
||||
|
||||
// Test for line 45: spread existing date filter when adding endDate
|
||||
it("should preserve existing date filter when adding endDate", () => {
|
||||
test("should preserve existing date filter when adding endDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
secret: true,
|
||||
}).openapi({
|
||||
ref: "webhookUpdate",
|
||||
description: "A webhook to update.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -49,6 +50,8 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const prismaData: Prisma.WebhookCreateInput = {
|
||||
environment: {
|
||||
connect: {
|
||||
@@ -60,6 +63,7 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
source,
|
||||
triggers,
|
||||
surveyIds,
|
||||
secret,
|
||||
};
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
|
||||
const ZCreatedUser = ZUser.pick({
|
||||
@@ -44,6 +45,9 @@ const ZCreateUserAction = z.object({
|
||||
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
|
||||
"CAPTCHA verification required"
|
||||
),
|
||||
isFormbricksCloud: z.boolean(),
|
||||
subscribeToSecurityUpdates: z.boolean().optional(),
|
||||
subscribeToProductUpdates: z.boolean().optional(),
|
||||
});
|
||||
|
||||
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
||||
@@ -191,6 +195,13 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
parsedInput.inviteToken,
|
||||
parsedInput.emailVerificationDisabled
|
||||
);
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: user.email,
|
||||
isFormbricksCloud: parsedInput.isFormbricksCloud,
|
||||
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
||||
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
@@ -48,6 +49,7 @@ interface SignupFormProps {
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
turnstileSiteKey?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -69,6 +71,7 @@ export const SignupForm = ({
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
turnstileSiteKey,
|
||||
isFormbricksCloud,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -76,6 +79,8 @@ export const SignupForm = ({
|
||||
const inviteToken = searchParams?.get("inviteToken");
|
||||
const router = useRouter();
|
||||
const [turnstileToken, setTurnstileToken] = useState<string>();
|
||||
const [subscribeToSecurityUpdates, setSubscribeToSecurityUpdates] = useState(false);
|
||||
const [subscribeToProductUpdates, setSubscribeToProductUpdates] = useState(false);
|
||||
|
||||
const turnstile = useTurnstile();
|
||||
|
||||
@@ -110,6 +115,9 @@ export const SignupForm = ({
|
||||
inviteToken: inviteToken ?? "",
|
||||
emailVerificationDisabled,
|
||||
turnstileToken,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
});
|
||||
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
@@ -239,6 +247,43 @@ export const SignupForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLogin &&
|
||||
(isFormbricksCloud ? (
|
||||
<label
|
||||
htmlFor="product-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="product-updates"
|
||||
checked={subscribeToProductUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToProductUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.product_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.product_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="security-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="security-updates"
|
||||
checked={subscribeToSecurityUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToSecurityUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.security_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.security_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{showLogin && (
|
||||
<Button
|
||||
data-testid="signup-submit"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
@@ -76,6 +77,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ const CONFIG = {
|
||||
env.ENVIRONMENT === "staging"
|
||||
? "https://staging.ee.formbricks.com/api/licenses/check"
|
||||
: "https://ee.formbricks.com/api/licenses/check",
|
||||
// ENDPOINT: "https://localhost:8080/api/licenses/check",
|
||||
TIMEOUT_MS: 5000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { subscribeToMailingList, subscribeUserToMailingList } from "./mailing-subscription";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
globalThis.fetch = vi.fn();
|
||||
|
||||
describe("subscribeToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("should successfully subscribe to security mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Successfully subscribed to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should successfully subscribe to product-updates mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "product-updates",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when API returns non-ok response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe: 400" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ status: 400, error: "Bad Request" },
|
||||
"Failed to subscribe to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when fetch throws an error", async () => {
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe to mailing list" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Error subscribing to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return timeout error when request times out", async () => {
|
||||
const abortError = new Error("Aborted");
|
||||
abortError.name = "AbortError";
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(abortError);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Request timed out" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Mailing subscription request timed out"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeUserToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should subscribe to product-updates when isFormbricksCloud is true and subscribeToProductUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is true but subscribeToProductUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should subscribe to security when isFormbricksCloud is false and subscribeToSecurityUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
subscribeToProductUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is false but subscribeToSecurityUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: false,
|
||||
subscribeToProductUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not subscribe when both subscription flags are undefined", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should prioritize product-updates for cloud users even if security is also true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
// Should only call product-updates endpoint for cloud users
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export type TMailingListId = "security" | "product-updates";
|
||||
|
||||
const MAILING_LIST_ENDPOINTS: Record<TMailingListId, string> = {
|
||||
security: "https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
"product-updates": "https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
} as const;
|
||||
|
||||
const EE_SERVER_TIMEOUT_MS = 5000;
|
||||
|
||||
interface TSubscribeToMailingListParams {
|
||||
email: TUserEmail;
|
||||
listId: TMailingListId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a user to a mailing list via the EE server
|
||||
* @param email - The user's email address
|
||||
* @param listId - The mailing list ID ("security" or "product-updates")
|
||||
*/
|
||||
export const subscribeToMailingList = async ({
|
||||
email,
|
||||
listId,
|
||||
}: TSubscribeToMailingListParams): Promise<{ success: boolean; error?: string }> => {
|
||||
validateInputs([email, ZUserEmail.toLowerCase()]);
|
||||
|
||||
const endpoint = MAILING_LIST_ENDPOINTS[listId];
|
||||
if (!endpoint) {
|
||||
logger.error({ listId }, "Invalid mailing list ID");
|
||||
return { success: false, error: "Invalid mailing list ID" };
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), EE_SERVER_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(
|
||||
{ status: response.status, error: errorText },
|
||||
`Failed to subscribe to ${listId} mailing list`
|
||||
);
|
||||
return { success: false, error: `Failed to subscribe: ${response.status}` };
|
||||
}
|
||||
|
||||
logger.info({ listId }, `Successfully subscribed to ${listId} mailing list`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.error({ listId }, "Mailing subscription request timed out");
|
||||
return { success: false, error: "Request timed out" };
|
||||
}
|
||||
|
||||
logger.error(error, `Error subscribing to ${listId} mailing list`);
|
||||
return { success: false, error: "Failed to subscribe to mailing list" };
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeUserToMailingList = async ({
|
||||
email,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
}: {
|
||||
email: TUserEmail;
|
||||
isFormbricksCloud: boolean;
|
||||
subscribeToSecurityUpdates?: boolean;
|
||||
subscribeToProductUpdates?: boolean;
|
||||
}): Promise<void> => {
|
||||
if (isFormbricksCloud && subscribeToProductUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "product-updates" });
|
||||
} else if (!isFormbricksCloud && subscribeToSecurityUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "security" });
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { Webhook as WebhookIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -12,6 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
|
||||
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
|
||||
import { WebhookCreatedModal } from "@/modules/integrations/webhooks/components/webhook-created-modal";
|
||||
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
@@ -142,7 +144,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
});
|
||||
if (createWebhookActionResult?.data) {
|
||||
router.refresh();
|
||||
setOpenWithStates(false);
|
||||
setCreatedWebhook(createWebhookActionResult.data);
|
||||
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
|
||||
@@ -156,21 +158,27 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenWithStates = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
const resetAndClose = () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
setTestEndpointInput("");
|
||||
setEndpointAccessible(undefined);
|
||||
setSelectedSurveys([]);
|
||||
setSelectedTriggers([]);
|
||||
setSelectedAllSurveys(false);
|
||||
setCreatedWebhook(null);
|
||||
};
|
||||
|
||||
// Show success dialog with secret after webhook creation
|
||||
if (createdWebhook) {
|
||||
return <WebhookCreatedModal open={open} webhook={createdWebhook} onClose={resetAndClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<Dialog open={open} onOpenChange={resetAndClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<Webhook />
|
||||
<WebhookIcon />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.webhooks.add_webhook_description")}
|
||||
@@ -249,12 +257,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpenWithStates(false);
|
||||
}}>
|
||||
<Button type="button" variant="secondary" onClick={resetAndClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={creatingWebhook}>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface WebhookCreatedModalProps {
|
||||
open: boolean;
|
||||
webhook: Webhook;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const WebhookCreatedModal = ({ open, webhook, onClose }: WebhookCreatedModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<CheckIcon className="h-6 w-6 text-green-500" />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.webhook_created")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.integrations.webhooks.copy_secret_now")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4 pb-4">
|
||||
<div className="col-span-1">
|
||||
<Label>{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input type="text" readOnly value={webhook.secret ?? ""} className="font-mono text-sm" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{t("environments.integrations.webhooks.secret_copy_warning")}
|
||||
</p>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||
target="_blank"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onClose}>
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOff, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -48,6 +48,15 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
@@ -113,6 +122,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
toast.error(t("common.please_select_at_least_one_survey"));
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||
if (!endpointHitSuccessfully) {
|
||||
return;
|
||||
@@ -196,6 +206,60 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webhook.secret && (
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="secret">{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showSecret ? "text" : "password"}
|
||||
id="secret"
|
||||
readOnly
|
||||
value={webhook.secret}
|
||||
className="pr-10 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
|
||||
onClick={() => setShowSecret(!showSecret)}>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-5 w-5 text-slate-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.webhooks.secret_description")}
|
||||
</p>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||
target="_blank"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
|
||||
<TriggerCheckboxGroup
|
||||
|
||||
@@ -35,6 +35,7 @@ export const WebhookTable = ({
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
secret: null,
|
||||
});
|
||||
|
||||
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ResourceNotFoundError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { TWebhookInput } from "../types/webhooks";
|
||||
@@ -59,15 +61,19 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<boolean> => {
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
try {
|
||||
if (isDiscordWebhook(webhookInput.url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
await prisma.webhook.create({
|
||||
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const webhook = await prisma.webhook.create({
|
||||
data: {
|
||||
...webhookInput,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
@@ -76,7 +82,7 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -121,13 +127,22 @@ export const testEndpoint = async (url: string): Promise<boolean> => {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
const body = JSON.stringify({ event: "testEndpoint" });
|
||||
|
||||
// Generate a temporary test secret and signature for consistency with actual webhooks
|
||||
const testSecret = generateWebhookSecret();
|
||||
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event: "testEndpoint",
|
||||
}),
|
||||
body,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
"webhook-signature": signature,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ export const AddApiKeyModal = ({
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[8rem]">
|
||||
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
|
||||
{projectOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
@@ -286,7 +286,7 @@ export const AddApiKeyModal = ({
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[8rem] capitalize">
|
||||
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto capitalize">
|
||||
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
|
||||
<DropdownMenuItem
|
||||
key={env.id}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
@@ -57,6 +58,7 @@ export const SignupPage = async () => {
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface AutoSaveIndicatorProps {
|
||||
isDraft: boolean;
|
||||
lastSaved: Date | null;
|
||||
}
|
||||
|
||||
export const AutoSaveIndicator = ({ isDraft, lastSaved }: AutoSaveIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSaved) {
|
||||
setShowSaved(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSaved(false);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [lastSaved]);
|
||||
|
||||
const isSavedState = isDraft && showSaved;
|
||||
|
||||
const text = useMemo(() => {
|
||||
if (!isDraft) {
|
||||
return t("environments.surveys.edit.auto_save_disabled");
|
||||
}
|
||||
|
||||
if (showSaved) {
|
||||
return t("environments.surveys.edit.progress_saved");
|
||||
}
|
||||
|
||||
return t("environments.surveys.edit.auto_save_on");
|
||||
}, [isDraft, showSaved, t]);
|
||||
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex cursor-default items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors duration-300",
|
||||
isSavedState
|
||||
? "border-green-600 bg-green-50 text-green-800"
|
||||
: "border-slate-200 bg-slate-100 text-slate-600"
|
||||
)}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipRenderer
|
||||
shouldRender={!isDraft}
|
||||
tooltipContent={t("environments.surveys.edit.auto_save_disabled_tooltip")}
|
||||
className="max-w-64 text-center">
|
||||
{badge}
|
||||
</TooltipRenderer>
|
||||
);
|
||||
};
|
||||
@@ -33,9 +33,10 @@ export const ResponseOptionsCard = ({
|
||||
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
|
||||
const [recaptchaToggle, setRecaptchaToggle] = useState(localSurvey.recaptcha?.enabled ?? false);
|
||||
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
|
||||
const [singleResponsePerEmailToggle, setSingleResponsePerEmailToggle] = useState(
|
||||
localSurvey.isSingleResponsePerEmailEnabled
|
||||
);
|
||||
const [captureIpToggle, setCaptureIpToggle] = useState(localSurvey.isCaptureIpEnabled);
|
||||
|
||||
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
|
||||
heading: t("environments.surveys.edit.survey_completed_heading"),
|
||||
@@ -90,7 +91,7 @@ export const ResponseOptionsCard = ({
|
||||
};
|
||||
|
||||
const handleSingleResponsePerEmailToggle = () => {
|
||||
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
|
||||
setSingleResponsePerEmailToggle(!singleResponsePerEmailToggle);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
|
||||
@@ -117,6 +118,11 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
|
||||
const handleCaptureIpToggle = () => {
|
||||
setCaptureIpToggle(!captureIpToggle);
|
||||
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
@@ -199,7 +205,7 @@ export const ResponseOptionsCard = ({
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
@@ -237,7 +243,7 @@ export const ResponseOptionsCard = ({
|
||||
value={localSurvey.autoComplete?.toString()}
|
||||
onChange={handleInputResponse}
|
||||
onBlur={handleInputResponseBlur}
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.completed_responses")}
|
||||
</p>
|
||||
@@ -304,7 +310,7 @@ export const ResponseOptionsCard = ({
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
className="mt-2 mb-4 bg-white"
|
||||
name="heading"
|
||||
defaultValue={surveyClosedMessage.heading}
|
||||
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
|
||||
@@ -333,7 +339,7 @@ export const ResponseOptionsCard = ({
|
||||
<div className="m-1">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="preventDoubleSubmission"
|
||||
isChecked={isSingleResponsePerEmailEnabledToggle}
|
||||
isChecked={singleResponsePerEmailToggle}
|
||||
onToggle={handleSingleResponsePerEmailToggle}
|
||||
title={t("environments.surveys.edit.prevent_double_submission")}
|
||||
description={t("environments.surveys.edit.prevent_double_submission_description")}
|
||||
@@ -380,6 +386,13 @@ export const ResponseOptionsCard = ({
|
||||
title={t("environments.surveys.edit.hide_back_button")}
|
||||
description={t("environments.surveys.edit.hide_back_button_description")}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="captureIp"
|
||||
isChecked={captureIpToggle}
|
||||
onToggle={handleCaptureIpToggle}
|
||||
title={t("environments.surveys.edit.capture_ip_address")}
|
||||
description={t("environments.surveys.edit.capture_ip_address_description")}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { updateSurveyAction, updateSurveyDraftAction } from "../actions";
|
||||
import { isSurveyValid } from "../lib/validation";
|
||||
import { AutoSaveIndicator } from "./auto-save-indicator";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -68,7 +69,14 @@ export const SurveyMenuBar = ({
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
|
||||
const isSuccessfullySavedRef = useRef(false);
|
||||
const isAutoSavingRef = useRef(false);
|
||||
|
||||
// Refs for interval-based auto-save (to access current values without re-creating interval)
|
||||
const localSurveyRef = useRef(localSurvey);
|
||||
const surveyRef = useRef(survey);
|
||||
const isSurveySavingRef = useRef(isSurveySaving);
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
@@ -80,6 +88,19 @@ export const SurveyMenuBar = ({
|
||||
setIsLinkSurvey(localSurvey.type === "link");
|
||||
}, [localSurvey.type]);
|
||||
|
||||
// Keep refs updated for interval-based auto-save
|
||||
useEffect(() => {
|
||||
localSurveyRef.current = localSurvey;
|
||||
}, [localSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
surveyRef.current = survey;
|
||||
}, [survey]);
|
||||
|
||||
useEffect(() => {
|
||||
isSurveySavingRef.current = isSurveySaving;
|
||||
}, [isSurveySaving]);
|
||||
|
||||
// Reset the successfully saved flag when survey prop updates (page refresh complete)
|
||||
useEffect(() => {
|
||||
if (isSuccessfullySavedRef.current) {
|
||||
@@ -228,6 +249,52 @@ export const SurveyMenuBar = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
// Interval-based auto-save for draft surveys (every 10 seconds)
|
||||
useEffect(() => {
|
||||
// Only set up interval for draft surveys
|
||||
if (localSurvey.status !== "draft") return;
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
// Skip if tab is not visible (no computation, no API calls for background tabs)
|
||||
if (document.hidden) return;
|
||||
|
||||
// Skip if already saving (manual or auto)
|
||||
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
|
||||
|
||||
// Check for changes using refs (avoids re-creating interval on every change)
|
||||
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
|
||||
const { updatedAt: surveyUpdatedAt, ...surveyRest } = surveyRef.current;
|
||||
|
||||
// Skip if no changes
|
||||
if (isEqual(localSurveyRest, surveyRest)) return;
|
||||
|
||||
isAutoSavingRef.current = true;
|
||||
|
||||
try {
|
||||
const currentSurvey = localSurveyRef.current;
|
||||
const updatedSurveyResponse = await updateSurveyDraftAction({
|
||||
...currentSurvey,
|
||||
segment: currentSurvey.segment?.id === "temp" ? null : currentSurvey.segment,
|
||||
} as unknown as TSurveyDraft);
|
||||
|
||||
if (updatedSurveyResponse?.data) {
|
||||
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
|
||||
// This keeps the UI stable while still tracking that changes have been saved.
|
||||
// The comparison uses refs, so this prevents unnecessary re-saves.
|
||||
surveyRef.current = { ...updatedSurveyResponse.data };
|
||||
isSuccessfullySavedRef.current = true;
|
||||
setLastAutoSaved(new Date());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isAutoSavingRef.current = false;
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [localSurvey.status]);
|
||||
|
||||
// Add new handler after handleSurveySave
|
||||
const handleSurveySaveDraft = async (): Promise<boolean> => {
|
||||
setIsSurveySaving(true);
|
||||
@@ -401,6 +468,7 @@ export const SurveyMenuBar = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
|
||||
{!isStorageConfigured && (
|
||||
<div>
|
||||
<Alert variant="warning" size="small">
|
||||
@@ -427,6 +495,7 @@ export const SurveyMenuBar = ({
|
||||
)}
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
data-save-button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("Survey Editor Library Tests", () => {
|
||||
vi.mocked(getOrganizationAIKeys).mockResolvedValue(mockOrganization as any);
|
||||
});
|
||||
|
||||
test("should handle languages update", async () => {
|
||||
test("should handle languages update with multiple languages", async () => {
|
||||
const updatedSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [
|
||||
@@ -219,6 +219,60 @@ describe("Survey Editor Library Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle languages update with single default language", async () => {
|
||||
// This tests the fix for the bug where languages.length === 1 would incorrectly
|
||||
// set updatedLanguageIds to [] causing the default language to be removed
|
||||
const updatedSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [
|
||||
{
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
alias: null,
|
||||
projectId: "project1",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await updateSurvey(updatedSurvey);
|
||||
|
||||
// Verify that prisma.survey.update was called
|
||||
expect(prisma.survey.update).toHaveBeenCalled();
|
||||
|
||||
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
|
||||
|
||||
// The key test: when languages.length === 1, we should still process language updates
|
||||
// and NOT delete the language. Before the fix, languages.length > 1 would fail this case.
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall.where).toEqual({ id: "survey123" });
|
||||
expect(updateCall.data).toBeDefined();
|
||||
});
|
||||
|
||||
test("should remove all languages when empty array is passed", async () => {
|
||||
const updatedSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [],
|
||||
};
|
||||
|
||||
await updateSurvey(updatedSurvey);
|
||||
|
||||
// Verify that prisma.survey.update was called
|
||||
expect(prisma.survey.update).toHaveBeenCalled();
|
||||
|
||||
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
|
||||
|
||||
// When languages is empty array, all existing languages should be removed
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall.where).toEqual({ id: "survey123" });
|
||||
expect(updateCall.data).toBeDefined();
|
||||
});
|
||||
|
||||
test("should delete private segment for non-app type surveys", async () => {
|
||||
const mockSegment: TSegment = {
|
||||
id: "segment1",
|
||||
|
||||
@@ -43,7 +43,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
? currentSurvey.languages.map((l) => l.language.id)
|
||||
: [];
|
||||
const updatedLanguageIds =
|
||||
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
const enabledLanguageIds = languages.map((language) => {
|
||||
if (language.enabled) return language.language.id;
|
||||
});
|
||||
|
||||
@@ -168,7 +168,6 @@ export const getElementTypes = (t: TFunction): TElement[] => [
|
||||
icon: MousePointerClickIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
ctaButtonLabel: createI18nString(t("templates.book_interview"), []),
|
||||
buttonUrl: "",
|
||||
buttonExternal: true,
|
||||
@@ -182,7 +181,6 @@ export const getElementTypes = (t: TFunction): TElement[] => [
|
||||
icon: CheckIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
label: createI18nString("", []),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export const selectSurvey = {
|
||||
autoComplete: true,
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
|
||||
@@ -48,6 +48,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
redirectUrl: true,
|
||||
pin: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
|
||||
// Single use configuration
|
||||
singleUse: true,
|
||||
|
||||
@@ -47,10 +47,10 @@ export const getMetadataForLinkSurvey = async (
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
follow: false,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
follow: false,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,4 +43,5 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
isBackButtonHidden: false,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
isCaptureIpEnabled: false,
|
||||
});
|
||||
|
||||
@@ -64,11 +64,13 @@
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.filter-scrollbar::-webkit-scrollbar {
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -24,6 +24,7 @@ const nextConfig = {
|
||||
outputFileTracingIncludes: {
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
turbopack: {},
|
||||
experimental: {},
|
||||
transpilePackages: ["@formbricks/database"],
|
||||
images: {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
|
||||
"build:dev": "pnpm run build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"test": "dotenv -e ../../.env -- vitest run",
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
"generate-api-specs": "./scripts/openapi/generate.sh",
|
||||
@@ -46,7 +46,7 @@
|
||||
"@lexical/rich-text": "0.36.2",
|
||||
"@lexical/table": "0.36.2",
|
||||
"@opentelemetry/exporter-prometheus": "0.203.0",
|
||||
"@opentelemetry/host-metrics": "0.36.0",
|
||||
"@opentelemetry/host-metrics": "0.38.0",
|
||||
"@opentelemetry/instrumentation": "0.203.0",
|
||||
"@opentelemetry/instrumentation-http": "0.203.0",
|
||||
"@opentelemetry/instrumentation-runtime-node": "0.17.1",
|
||||
@@ -101,7 +101,7 @@
|
||||
"lucide-react": "0.507.0",
|
||||
"markdown-it": "14.1.0",
|
||||
"mime-types": "3.0.1",
|
||||
"next": "15.5.9",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "4.24.12",
|
||||
"next-safe-action": "7.10.8",
|
||||
"node-fetch": "3.3.2",
|
||||
@@ -133,7 +133,9 @@
|
||||
"webpack": "5.99.8",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "3.24.4",
|
||||
"zod-openapi": "4.2.4"
|
||||
"zod-openapi": "4.2.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
@@ -156,7 +158,7 @@
|
||||
"autoprefixer": "10.4.21",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "16.5.0",
|
||||
"esbuild": "0.25.10",
|
||||
"esbuild": "0.25.11",
|
||||
"postcss": "8.5.3",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"ts-node": "10.9.2",
|
||||
|
||||
@@ -56,7 +56,7 @@ const handleDomainAwareRouting = (request: NextRequest): Response | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const middleware = async (originalRequest: NextRequest) => {
|
||||
export const proxy = async (originalRequest: NextRequest) => {
|
||||
// Handle domain-aware routing first
|
||||
const domainResponse = handleDomainAwareRouting(originalRequest);
|
||||
if (domainResponse) return domainResponse;
|
||||
@@ -48,10 +48,10 @@ run_with_timeout() {
|
||||
|
||||
|
||||
echo "🗃️ Running database migrations..."
|
||||
run_with_timeout 300 "database migration" sh -c '(cd packages/database && npm run db:migrate:deploy)'
|
||||
run_with_timeout 300 "database migration" node packages/database/dist/scripts/apply-migrations.js
|
||||
|
||||
echo "🗃️ Running SAML database setup..."
|
||||
run_with_timeout 60 "SAML database setup" sh -c '(cd packages/database && npm run db:create-saml-database:deploy)'
|
||||
run_with_timeout 60 "SAML database setup" node packages/database/dist/scripts/create-saml-database.js
|
||||
|
||||
echo "✅ Database setup completed"
|
||||
echo "🚀 Starting Next.js server..."
|
||||
|
||||
@@ -5868,6 +5868,7 @@
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"webp",
|
||||
"ico",
|
||||
"pdf",
|
||||
"eml",
|
||||
"doc",
|
||||
@@ -5883,6 +5884,7 @@
|
||||
"avi",
|
||||
"mkv",
|
||||
"webm",
|
||||
"mp3",
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
|
||||
@@ -48,6 +48,169 @@ If you encounter any issues or need help setting up webhooks, feel free to reach
|
||||
|
||||
---
|
||||
|
||||
## Webhook Security with Standard Webhooks
|
||||
|
||||
Formbricks implements the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) specification to ensure webhook requests can be verified as genuinely originating from Formbricks.
|
||||
|
||||
### Webhook Headers
|
||||
|
||||
Every webhook request includes the following headers:
|
||||
|
||||
| Header | Description | Example |
|
||||
| ------------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| `webhook-id` | Unique message identifier | `019ba292-c1f6-7618-aaf2-ecf8e39d1cc7` |
|
||||
| `webhook-timestamp` | Unix timestamp (seconds) when the webhook was sent | `1704547200` |
|
||||
| `webhook-signature` | HMAC-SHA256 signature (only if secret is configured) | `v1,K3Q2bXlzZWNyZXQ=` |
|
||||
|
||||
### Signing Secret
|
||||
|
||||
When you create a webhook (via the UI or API), Formbricks automatically generates a unique signing secret for that webhook. The secret follows the Standard Webhooks format: `whsec_` followed by a base64-encoded random value.
|
||||
|
||||
**Via UI:** After creating a webhook, the signing secret is displayed immediately. Copy and store it securely—you can also view it later in the webhook settings.
|
||||
|
||||
**Via API:** The signing secret is returned in the webhook creation response.
|
||||
|
||||
This secret is used to generate the HMAC signature included in each webhook request, allowing you to verify the authenticity of incoming webhooks.
|
||||
|
||||
### Signature Verification
|
||||
|
||||
The signature is computed as follows:
|
||||
|
||||
```
|
||||
signed_content = "{webhook-id}.{webhook-timestamp}.{body}"
|
||||
signature = base64(HMAC-SHA256(secret, signed_content))
|
||||
header_value = "v1,{signature}"
|
||||
```
|
||||
|
||||
### Validating Webhooks
|
||||
|
||||
To validate incoming webhook requests:
|
||||
|
||||
1. Extract the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers
|
||||
2. Verify the timestamp is within an acceptable tolerance (e.g., 5 minutes) to prevent replay attacks
|
||||
3. Decode the secret by stripping the `whsec_` prefix and base64 decoding the rest
|
||||
4. Compute the expected signature using HMAC-SHA256 with the decoded secret
|
||||
5. Compare your computed signature with the received signature (after stripping the `v1,` prefix)
|
||||
|
||||
### Node.js Verification Functions
|
||||
|
||||
```javascript
|
||||
const crypto = require("crypto");
|
||||
|
||||
const WEBHOOK_TOLERANCE_IN_SECONDS = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Decodes a Formbricks webhook secret (whsec_...) to raw bytes
|
||||
*/
|
||||
function decodeSecret(secret) {
|
||||
const base64 = secret.startsWith("whsec_") ? secret.slice(6) : secret;
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the webhook timestamp is within tolerance
|
||||
* @throws {Error} if timestamp is too old or too new
|
||||
*/
|
||||
function verifyTimestamp(timestampHeader) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const timestamp = parseInt(timestampHeader, 10);
|
||||
|
||||
if (isNaN(timestamp)) {
|
||||
throw new Error("Invalid timestamp");
|
||||
}
|
||||
|
||||
if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_IN_SECONDS) {
|
||||
throw new Error("Timestamp outside tolerance window");
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the expected signature for a webhook payload
|
||||
*/
|
||||
function computeSignature(webhookId, timestamp, body, secret) {
|
||||
const signedContent = `${webhookId}.${timestamp}.${body}`;
|
||||
const secretBytes = decodeSecret(secret);
|
||||
return crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Formbricks webhook request
|
||||
* @param {string} body - Raw request body as string
|
||||
* @param {object} headers - Object containing webhook-id, webhook-timestamp, webhook-signature
|
||||
* @param {string} secret - Your webhook secret (whsec_...)
|
||||
* @returns {boolean} true if valid
|
||||
* @throws {Error} if verification fails
|
||||
*/
|
||||
function verifyWebhook(body, headers, secret) {
|
||||
const webhookId = headers["webhook-id"];
|
||||
const webhookTimestamp = headers["webhook-timestamp"];
|
||||
const webhookSignature = headers["webhook-signature"];
|
||||
|
||||
if (!webhookId || !webhookTimestamp || !webhookSignature) {
|
||||
throw new Error("Missing required webhook headers");
|
||||
}
|
||||
|
||||
// Verify timestamp
|
||||
const timestamp = verifyTimestamp(webhookTimestamp);
|
||||
|
||||
// Compute expected signature
|
||||
const expectedSignature = computeSignature(webhookId, timestamp, body, secret);
|
||||
|
||||
// Extract signature from header (format: "v1,{signature}")
|
||||
const receivedSignature = webhookSignature.split(",")[1];
|
||||
|
||||
if (!receivedSignature) {
|
||||
throw new Error("Invalid signature format");
|
||||
}
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
|
||||
const receivedBuffer = Buffer.from(receivedSignature, "utf8");
|
||||
|
||||
if (
|
||||
expectedBuffer.length !== receivedBuffer.length ||
|
||||
!crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
|
||||
) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { verifyWebhook, decodeSecret, computeSignature, verifyTimestamp };
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
// In your webhook handler, use the raw body (not parsed JSON)
|
||||
try {
|
||||
verifyWebhook(rawBody, req.headers, process.env.FORMBRICKS_WEBHOOK_SECRET);
|
||||
const payload = JSON.parse(rawBody);
|
||||
// Process verified webhook...
|
||||
} catch (error) {
|
||||
// Verification failed - reject the request
|
||||
console.error("Webhook verification failed:", error.message);
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
Always use the **raw request body** (as a string) for signature verification, not the parsed JSON object.
|
||||
Parsing and re-stringifying can change the formatting and break signature validation.
|
||||
</Note>
|
||||
|
||||
### Using Standard Webhooks Libraries
|
||||
|
||||
You can also use the official [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks#libraries) available for various languages:
|
||||
|
||||
- **Node.js**: `npm install standardwebhooks`
|
||||
- **Python**: `pip install standardwebhooks`
|
||||
- **Go, Ruby, Java, Kotlin, PHP, Rust**: See the [Standard Webhooks GitHub](https://github.com/standard-webhooks/standard-webhooks)
|
||||
|
||||
---
|
||||
|
||||
## Example Webhook Payloads
|
||||
|
||||
We provide the following webhook payloads, `responseCreated`, `responseUpdated`, and `responseFinished`.
|
||||
@@ -82,10 +245,10 @@ Example of Response Created webhook payload:
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"status": "inProgress",
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
@@ -133,10 +296,10 @@ Example of Response Updated webhook payload:
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"status": "inProgress",
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
@@ -185,10 +348,10 @@ Example of Response Finished webhook payload:
|
||||
},
|
||||
"singleUseId": null,
|
||||
"survey": {
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"status": "inProgress",
|
||||
"title": "Customer Satisfaction Survey",
|
||||
"type": "link",
|
||||
"status": "inProgress",
|
||||
"createdAt": "2025-07-20T10:30:00.000Z",
|
||||
"updatedAt": "2025-07-24T07:45:00.000Z"
|
||||
},
|
||||
"surveyId": "surveyId",
|
||||
|
||||
@@ -7,6 +7,11 @@ type: application
|
||||
# Helm chart Version
|
||||
version: 0.0.0-dev
|
||||
|
||||
# This is the version number of the application being deployed.
|
||||
appVersion: "3.7.0"
|
||||
|
||||
icon: https://formbricks.com/favicon.ico
|
||||
|
||||
keywords:
|
||||
- formbricks
|
||||
- postgresql
|
||||
|
||||
@@ -84,13 +84,18 @@ Redis Access:
|
||||
---
|
||||
|
||||
Environment Variables:
|
||||
The following environment variables have been automatically generated:
|
||||
The following environment variables have been configured:
|
||||
|
||||
- `NEXTAUTH_SECRET`: A random 32-character string
|
||||
- `ENCRYPTION_KEY`: A random 32-character string
|
||||
- `CRON_SECRET`: A random 32-character string
|
||||
- 'EMAIL_VERIFICATION_DISABLED': 1 # By Default email verification is disabled, configure SMTP settings to enable(https://formbricks.com/docs/self-hosting/configuration/smtp)
|
||||
- 'PASSWORD_RESET_DISABLED': 1 # By Default password reset is disabled, configure SMTP settings to enable(https://formbricks.com/docs/self-hosting/configuration/smtp)
|
||||
- `WEBAPP_URL`: {{ .Values.formbricks.webappUrl }}
|
||||
- `NEXTAUTH_URL`: {{ .Values.formbricks.webappUrl }}
|
||||
{{- if .Values.formbricks.publicUrl }}
|
||||
- `PUBLIC_URL`: {{ .Values.formbricks.publicUrl }}
|
||||
{{- end }}
|
||||
- `NEXTAUTH_SECRET`: A random 32-character string (auto-generated)
|
||||
- `ENCRYPTION_KEY`: A random 32-character string (auto-generated)
|
||||
- `CRON_SECRET`: A random 32-character string (auto-generated)
|
||||
- `EMAIL_VERIFICATION_DISABLED`: 1 # By Default email verification is disabled, configure SMTP settings to enable(https://formbricks.com/docs/self-hosting/configuration/smtp)
|
||||
- `PASSWORD_RESET_DISABLED`: 1 # By Default password reset is disabled, configure SMTP settings to enable(https://formbricks.com/docs/self-hosting/configuration/smtp)
|
||||
|
||||
Retrieve them using:
|
||||
```sh
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{{- $postgresAdminPassword := include "formbricks.postgresAdminPassword" . }}
|
||||
{{- $postgresUserPassword := include "formbricks.postgresUserPassword" . }}
|
||||
{{- $redisPassword := include "formbricks.redisPassword" . }}
|
||||
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -11,6 +12,12 @@ metadata:
|
||||
labels:
|
||||
{{- include "formbricks.labels" . | nindent 4 }}
|
||||
data:
|
||||
# Formbricks application URLs
|
||||
WEBAPP_URL: {{ $webappUrl | b64enc }}
|
||||
NEXTAUTH_URL: {{ $webappUrl | b64enc }}
|
||||
{{- if .Values.formbricks.publicUrl }}
|
||||
PUBLIC_URL: {{ .Values.formbricks.publicUrl | b64enc }}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.enabled }}
|
||||
REDIS_URL: {{ printf "redis://:%s@formbricks-redis-master:6379" $redisPassword | b64enc }}
|
||||
{{- else }}
|
||||
@@ -21,7 +28,7 @@ data:
|
||||
{{- else }}
|
||||
DATABASE_URL: {{ .Values.postgresql.externalDatabaseUrl | b64enc }}
|
||||
{{- end }}
|
||||
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
|
||||
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
|
||||
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
|
||||
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
|
||||
{{- if and (.Values.enterprise.licenseKey) (ne .Values.enterprise.licenseKey "") }}
|
||||
|
||||
@@ -7,15 +7,13 @@ metadata:
|
||||
namespace: {{ include "formbricks.namespace" . }}
|
||||
labels:
|
||||
{{- include "formbricks.labels" $ | nindent 4 }}
|
||||
{{- if .Values.serviceMonitor.additionalLabels }}
|
||||
{{ toYaml .Values.serviceMonitor.additionalLabels | indent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.serviceMonitor.annotations }}
|
||||
{{- if .Values.serviceMonitor.additionalLabels }}
|
||||
{{- toYaml .Values.serviceMonitor.additionalLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.serviceMonitor.annotations }}
|
||||
annotations:
|
||||
{{- end }}
|
||||
{{- if or .Values.serviceMonitor.annotations }}
|
||||
{{ toYaml .Values.serviceMonitor.annotations | indent 4 }}
|
||||
{{- end }}
|
||||
{{- toYaml .Values.serviceMonitor.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
|
||||
@@ -10,6 +10,17 @@ componentOverride: ""
|
||||
# Defaults to the chart name if not set.
|
||||
partOfOverride: ""
|
||||
|
||||
##########################################################
|
||||
# Formbricks Configuration
|
||||
##########################################################
|
||||
formbricks:
|
||||
# REQUIRED: Base URL of the site (e.g., https://formbricks.example.com)
|
||||
# This is used for WEBAPP_URL and NEXTAUTH_URL
|
||||
webappUrl: ""
|
||||
|
||||
# Optional: Public URL for surveys (defaults to webappUrl if not set)
|
||||
publicUrl: ""
|
||||
|
||||
##########################################################
|
||||
# Enterprise Configuration
|
||||
##########################################################
|
||||
@@ -76,6 +87,7 @@ deployment:
|
||||
# Application container image
|
||||
image:
|
||||
repository: "ghcr.io/formbricks/formbricks"
|
||||
tag: "" # Defaults to appVersion if not set
|
||||
digest: "" # If set, digest takes precedence over the tag
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
||||
+6
-4
@@ -44,8 +44,9 @@
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"next": "16.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/identity": "4.13.0",
|
||||
@@ -86,10 +87,11 @@
|
||||
"axios": ">=1.12.2",
|
||||
"node-forge": ">=1.3.2",
|
||||
"tar-fs": "2.1.4",
|
||||
"typeorm": ">=0.3.26"
|
||||
"typeorm": ">=0.3.26",
|
||||
"systeminformation": "5.27.14"
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Webhook" ADD COLUMN "secret" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Survey" ADD COLUMN "isCaptureIpEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -38,7 +38,7 @@
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
|
||||
"db:seed:clear": "dotenv -e ../../.env -- tsx src/seed.ts --clear",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
|
||||
"db:start": "pnpm db:setup",
|
||||
"format": "prisma format",
|
||||
"generate": "prisma generate",
|
||||
|
||||
@@ -51,6 +51,7 @@ model Webhook {
|
||||
environmentId String
|
||||
triggers PipelineTriggers[]
|
||||
surveyIds String[]
|
||||
secret String?
|
||||
|
||||
@@index([environmentId])
|
||||
}
|
||||
@@ -385,6 +386,7 @@ model Survey {
|
||||
isVerifyEmailEnabled Boolean @default(false)
|
||||
isSingleResponsePerEmailEnabled Boolean @default(false)
|
||||
isBackButtonHidden Boolean @default(false)
|
||||
isCaptureIpEnabled Boolean @default(false)
|
||||
pin String?
|
||||
displayPercentage Decimal?
|
||||
languages SurveyLanguage[]
|
||||
|
||||
@@ -34,6 +34,7 @@ const MIGRATIONS_DIR = isBuilt
|
||||
? path.resolve(__dirname, "../migration") // From dist/scripts to dist/migration
|
||||
: path.resolve(__dirname, "../../migration"); // From src/scripts to migration
|
||||
const PRISMA_MIGRATIONS_DIR = path.resolve(__dirname, "../../migrations");
|
||||
const PRISMA_SCHEMA_PATH = path.resolve(__dirname, "../../schema.prisma");
|
||||
|
||||
const runMigrations = async (migrations: MigrationScript[]): Promise<void> => {
|
||||
logger.info(`Starting migrations: ${migrations.length.toString()} to run`);
|
||||
@@ -169,7 +170,7 @@ const runSingleMigration = async (migration: MigrationScript, index: number): Pr
|
||||
|
||||
// Run Prisma migrate
|
||||
// throws when migrate deploy fails
|
||||
await execAsync("pnpm prisma migrate deploy");
|
||||
await execAsync(`pnpm prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
|
||||
logger.info(`Successfully applied schema migration: ${migration.name}`);
|
||||
} catch (err) {
|
||||
logger.error(err, `Schema migration ${migration.name} failed`);
|
||||
|
||||
@@ -39,6 +39,9 @@ export const ZWebhook = z.object({
|
||||
surveyIds: z.array(z.string().cuid2()).openapi({
|
||||
description: "The IDs of the surveys ",
|
||||
}),
|
||||
secret: z.string().nullable().openapi({
|
||||
description: "The shared secret used to generate HMAC signatures for webhook requests",
|
||||
}),
|
||||
}) satisfies z.ZodType<Webhook>;
|
||||
|
||||
ZWebhook.openapi({
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ConsentProps {
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -45,6 +47,7 @@ function Consent({
|
||||
value = false,
|
||||
onChange,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -63,6 +66,7 @@ function Consent({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface CTAProps {
|
||||
onClick: () => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -50,6 +52,7 @@ function CTA({
|
||||
buttonExternal = false,
|
||||
onClick,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -73,6 +76,7 @@ function CTA({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
@@ -82,7 +86,7 @@ function CTA({
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{buttonExternal && (
|
||||
{buttonExternal ? (
|
||||
<div className="flex w-full justify-start">
|
||||
<Button
|
||||
id={inputId}
|
||||
@@ -95,7 +99,7 @@ function CTA({
|
||||
<SquareArrowOutUpRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ interface DateElementProps {
|
||||
onChange: (value: string) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Minimum date allowed (ISO format: YYYY-MM-DD) */
|
||||
minDate?: string;
|
||||
/** Maximum date allowed (ISO format: YYYY-MM-DD) */
|
||||
@@ -45,6 +47,7 @@ function DateElement({
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
minDate,
|
||||
maxDate,
|
||||
dir = "auto",
|
||||
@@ -152,6 +155,7 @@ function DateElement({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -37,6 +37,8 @@ interface FileUploadProps {
|
||||
allowedFileExtensions?: string[];
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Whether the component is in uploading state */
|
||||
@@ -219,6 +221,7 @@ function FileUpload({
|
||||
allowMultiple = false,
|
||||
allowedFileExtensions,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
isUploading = false,
|
||||
dir = "auto",
|
||||
@@ -279,6 +282,7 @@ function FileUpload({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -37,6 +37,8 @@ interface FormFieldProps {
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
/** Whether the entire form is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -57,6 +59,7 @@ function FormField({
|
||||
value = {},
|
||||
onChange,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -103,6 +106,7 @@ function FormField({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,8 @@ interface MatrixProps {
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -57,6 +59,7 @@ function Matrix({
|
||||
value = {},
|
||||
onChange,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -84,6 +87,7 @@ function Matrix({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -45,6 +45,8 @@ interface MultiSelectProps {
|
||||
onChange: (value: string[]) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display below the options */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -405,6 +407,7 @@ function MultiSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -473,6 +476,7 @@ function MultiSelect({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface NPSProps {
|
||||
colorCoding?: boolean;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -48,6 +50,7 @@ function NPS({
|
||||
upperLabel,
|
||||
colorCoding = false,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -171,6 +174,7 @@ function NPS({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface OpenTextProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
requiredLabel?: string;
|
||||
longAnswer?: boolean;
|
||||
inputType?: "text" | "email" | "url" | "phone" | "number";
|
||||
charLimit?: {
|
||||
@@ -37,6 +38,7 @@ function OpenText({
|
||||
inputId,
|
||||
onChange,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
longAnswer = false,
|
||||
inputType = "text",
|
||||
charLimit,
|
||||
@@ -72,6 +74,7 @@ function OpenText({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -37,6 +37,8 @@ interface PictureSelectProps {
|
||||
allowMulti?: boolean;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -59,6 +61,7 @@ function PictureSelect({
|
||||
onChange,
|
||||
allowMulti = false,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -96,6 +99,7 @@ function PictureSelect({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
@@ -37,6 +37,8 @@ interface RankingProps {
|
||||
onChange: (value: string[]) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator */
|
||||
requiredLabel?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
@@ -191,6 +193,7 @@ function Ranking({
|
||||
value = [],
|
||||
onChange,
|
||||
required = false,
|
||||
requiredLabel,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
@@ -249,6 +252,7 @@ function Ranking({
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user