mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 19:30:41 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d94fbc05f4 | |||
| 541ddc0c4d | |||
| 6d67fc288a | |||
| dffddd0bce | |||
| 4fca961cd5 | |||
| c22a55429d | |||
| dfc86e7dad | |||
| 3b520b5855 | |||
| 4326772989 | |||
| d209e411cb |
+1
-3
@@ -21,7 +21,6 @@ import {
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
@@ -100,7 +99,6 @@ const elementIcons = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
ipAddress: NetworkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -192,7 +190,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 ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -82,7 +82,6 @@ const mockPipelineInput = {
|
||||
},
|
||||
country: "USA",
|
||||
action: "Action Name",
|
||||
ipAddress: "203.0.113.7",
|
||||
} as TResponseMeta,
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
@@ -347,7 +346,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\nIP Address: 203.0.113.7";
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
|
||||
expect(airtableWriteData).toHaveBeenCalledWith(
|
||||
mockAirtableIntegration.config.key,
|
||||
mockAirtableIntegration.config.data[0],
|
||||
|
||||
@@ -31,7 +31,6 @@ 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,6 +1,5 @@
|
||||
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";
|
||||
@@ -9,7 +8,6 @@ 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";
|
||||
@@ -92,50 +90,28 @@ export const POST = async (request: Request) => {
|
||||
]);
|
||||
};
|
||||
|
||||
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, {
|
||||
const webhookPromises = webhooks.map((webhook) =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
|
||||
@@ -11,7 +11,6 @@ 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";
|
||||
@@ -137,13 +136,6 @@ 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,10 +19,6 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -63,7 +59,6 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
@@ -149,7 +144,6 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -4,15 +4,12 @@ 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,
|
||||
@@ -20,7 +17,6 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -120,13 +119,6 @@ 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,
|
||||
|
||||
@@ -4913,7 +4913,6 @@ 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,
|
||||
|
||||
+216
-226
File diff suppressed because it is too large
Load Diff
+1
-131
@@ -1,11 +1,8 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import * as crypto from "crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
// Import after unmocking
|
||||
import {
|
||||
generateStandardWebhookSignature,
|
||||
generateWebhookSecret,
|
||||
getWebhookSecretBytes,
|
||||
hashSecret,
|
||||
hashSha256,
|
||||
parseApiKeyV2,
|
||||
@@ -286,133 +283,6 @@ 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";
|
||||
|
||||
+1
-52
@@ -1,5 +1,5 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
|
||||
@@ -141,54 +141,3 @@ 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}`;
|
||||
};
|
||||
|
||||
@@ -208,7 +208,6 @@ const baseSurveyProperties = {
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
endings: [
|
||||
{
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
|
||||
@@ -56,7 +56,6 @@ export const selectSurvey = {
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domain",
|
||||
"done": "Fertig",
|
||||
"download": "Herunterladen",
|
||||
"draft": "Entwurf",
|
||||
"duplicate": "Duplikat",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
+216
-226
File diff suppressed because it is too large
Load Diff
@@ -197,7 +197,6 @@
|
||||
"docs": "Documentación",
|
||||
"documentation": "Documentación",
|
||||
"domain": "Dominio",
|
||||
"done": "Hecho",
|
||||
"download": "Descargar",
|
||||
"draft": "Borrador",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domaine",
|
||||
"done": "Terminé",
|
||||
"download": "Télécharger",
|
||||
"draft": "Brouillon",
|
||||
"duplicate": "Dupliquer",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "ドキュメント",
|
||||
"documentation": "ドキュメント",
|
||||
"domain": "ドメイン",
|
||||
"done": "完了",
|
||||
"download": "ダウンロード",
|
||||
"draft": "下書き",
|
||||
"duplicate": "複製",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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にラベルを付ける",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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": "カードの背景色",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
|
||||
"first_name": "名",
|
||||
"how_to_identify_users": "ユーザーを識別する方法",
|
||||
"ip_address": "IPアドレス",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完了 ⏳",
|
||||
"os": "OS",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Documentatie",
|
||||
"documentation": "Documentatie",
|
||||
"domain": "Domein",
|
||||
"done": "Klaar",
|
||||
"download": "Downloaden",
|
||||
"draft": "Voorlopige versie",
|
||||
"duplicate": "Duplicaat",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "baixar",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "Transferir",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Documentație",
|
||||
"documentation": "Documentație",
|
||||
"domain": "Domeniu",
|
||||
"done": "Gata",
|
||||
"download": "Descărcare",
|
||||
"draft": "Schiță",
|
||||
"duplicate": "Duplicități",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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ă",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Документация",
|
||||
"documentation": "Документация",
|
||||
"domain": "Домен",
|
||||
"done": "Готово",
|
||||
"download": "Скачать",
|
||||
"draft": "Черновик",
|
||||
"duplicate": "Дублировать",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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 для удобной идентификации",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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": "Цвет фона карточки",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
|
||||
"first_name": "Имя",
|
||||
"how_to_identify_users": "Как идентифицировать пользователей",
|
||||
"ip_address": "IP-адрес",
|
||||
"last_name": "Фамилия",
|
||||
"not_completed": "Не завершено ⏳",
|
||||
"os": "ОС",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domän",
|
||||
"done": "Klar",
|
||||
"download": "Ladda ner",
|
||||
"draft": "Utkast",
|
||||
"duplicate": "Duplicera",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"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",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "文档",
|
||||
"documentation": "文档",
|
||||
"domain": "域名",
|
||||
"done": "完成",
|
||||
"download": "下载",
|
||||
"draft": "草稿",
|
||||
"duplicate": "复制",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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 标注 标签 以 便于 识别",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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": "卡片 的 背景 颜色",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"error_downloading_responses": "下载答复时发生错误",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何 识别 用户",
|
||||
"ip_address": "IP地址",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "操作系统",
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"docs": "文件",
|
||||
"documentation": "文件",
|
||||
"domain": "網域",
|
||||
"done": "完成",
|
||||
"download": "下載",
|
||||
"draft": "草稿",
|
||||
"duplicate": "複製",
|
||||
@@ -784,26 +783,20 @@
|
||||
"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 加上標籤以便於識別",
|
||||
@@ -1197,8 +1190,6 @@
|
||||
"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": "卡片背景顏色",
|
||||
@@ -1655,7 +1646,6 @@
|
||||
"error_downloading_responses": "下載回應時發生錯誤",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何識別使用者",
|
||||
"ip_address": "IP 位址",
|
||||
"last_name": "姓氏",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "作業系統",
|
||||
|
||||
-5
@@ -123,11 +123,6 @@ 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;
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
secret: true,
|
||||
}).openapi({
|
||||
ref: "webhookUpdate",
|
||||
description: "A webhook to update.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -50,8 +49,6 @@ 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: {
|
||||
@@ -63,7 +60,6 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
source,
|
||||
triggers,
|
||||
surveyIds,
|
||||
secret,
|
||||
};
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { Webhook as WebhookIcon } from "lucide-react";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -12,7 +12,6 @@ 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 {
|
||||
@@ -52,7 +51,6 @@ 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 {
|
||||
@@ -144,7 +142,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
});
|
||||
if (createWebhookActionResult?.data) {
|
||||
router.refresh();
|
||||
setCreatedWebhook(createWebhookActionResult.data);
|
||||
setOpenWithStates(false);
|
||||
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
|
||||
@@ -158,27 +156,21 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
}
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setOpen(false);
|
||||
const setOpenWithStates = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
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={resetAndClose}>
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<WebhookIcon />
|
||||
<Webhook />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.webhooks.add_webhook_description")}
|
||||
@@ -257,7 +249,12 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={resetAndClose}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpenWithStates(false);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={creatingWebhook}>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"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 { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOff, TrashIcon } from "lucide-react";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -48,15 +48,6 @@ 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 {
|
||||
@@ -122,7 +113,6 @@ 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;
|
||||
@@ -206,60 +196,6 @@ 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,7 +35,6 @@ export const WebhookTable = ({
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
secret: null,
|
||||
});
|
||||
|
||||
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -9,7 +8,6 @@ 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";
|
||||
@@ -61,19 +59,15 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<boolean> => {
|
||||
try {
|
||||
if (isDiscordWebhook(webhookInput.url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const webhook = await prisma.webhook.create({
|
||||
await prisma.webhook.create({
|
||||
data: {
|
||||
...webhookInput,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
@@ -82,7 +76,7 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
||||
},
|
||||
});
|
||||
|
||||
return webhook;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -127,22 +121,13 @@ 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,
|
||||
body: JSON.stringify({
|
||||
event: "testEndpoint",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
"webhook-signature": signature,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -33,10 +33,9 @@ export const ResponseOptionsCard = ({
|
||||
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
|
||||
const [recaptchaToggle, setRecaptchaToggle] = useState(localSurvey.recaptcha?.enabled ?? false);
|
||||
const [singleResponsePerEmailToggle, setSingleResponsePerEmailToggle] = useState(
|
||||
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
|
||||
localSurvey.isSingleResponsePerEmailEnabled
|
||||
);
|
||||
const [captureIpToggle, setCaptureIpToggle] = useState(localSurvey.isCaptureIpEnabled);
|
||||
|
||||
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
|
||||
heading: t("environments.surveys.edit.survey_completed_heading"),
|
||||
@@ -91,7 +90,7 @@ export const ResponseOptionsCard = ({
|
||||
};
|
||||
|
||||
const handleSingleResponsePerEmailToggle = () => {
|
||||
setSingleResponsePerEmailToggle(!singleResponsePerEmailToggle);
|
||||
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
|
||||
@@ -118,11 +117,6 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
|
||||
const handleCaptureIpToggle = () => {
|
||||
setCaptureIpToggle(!captureIpToggle);
|
||||
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
@@ -205,7 +199,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 pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
@@ -243,7 +237,7 @@ export const ResponseOptionsCard = ({
|
||||
value={localSurvey.autoComplete?.toString()}
|
||||
onChange={handleInputResponse}
|
||||
onBlur={handleInputResponseBlur}
|
||||
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.completed_responses")}
|
||||
</p>
|
||||
@@ -310,7 +304,7 @@ export const ResponseOptionsCard = ({
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mt-2 mb-4 bg-white"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
name="heading"
|
||||
defaultValue={surveyClosedMessage.heading}
|
||||
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
|
||||
@@ -339,7 +333,7 @@ export const ResponseOptionsCard = ({
|
||||
<div className="m-1">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="preventDoubleSubmission"
|
||||
isChecked={singleResponsePerEmailToggle}
|
||||
isChecked={isSingleResponsePerEmailEnabledToggle}
|
||||
onToggle={handleSingleResponsePerEmailToggle}
|
||||
title={t("environments.surveys.edit.prevent_double_submission")}
|
||||
description={t("environments.surveys.edit.prevent_double_submission_description")}
|
||||
@@ -386,13 +380,6 @@ 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>
|
||||
|
||||
@@ -168,6 +168,7 @@ export const getElementTypes = (t: TFunction): TElement[] => [
|
||||
icon: MousePointerClickIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
ctaButtonLabel: createI18nString(t("templates.book_interview"), []),
|
||||
buttonUrl: "",
|
||||
buttonExternal: true,
|
||||
@@ -181,6 +182,7 @@ export const getElementTypes = (t: TFunction): TElement[] => [
|
||||
icon: CheckIcon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
subheader: createI18nString("", []),
|
||||
label: createI18nString("", []),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ export const selectSurvey = {
|
||||
autoComplete: true,
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
|
||||
@@ -48,7 +48,6 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
redirectUrl: true,
|
||||
pin: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
|
||||
// Single use configuration
|
||||
singleUse: true,
|
||||
|
||||
@@ -43,5 +43,4 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
isBackButtonHidden: false,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
isCaptureIpEnabled: false,
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ test.describe("Onboarding Flow Test", async () => {
|
||||
await page.getByPlaceholder("e.g. Formbricks").fill(projectName);
|
||||
await page.locator("#form-next-button").click();
|
||||
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.getByRole("button", { name: "I will do it later" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page.getByText(projectName)).toBeVisible();
|
||||
|
||||
@@ -121,7 +121,7 @@ export const finishOnboarding = async (
|
||||
await page.locator("#form-next-button").click();
|
||||
|
||||
if (projectChannel !== "link") {
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.getByRole("button", { name: "I will do it later" }).click();
|
||||
}
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
@@ -48,169 +48,6 @@ 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`.
|
||||
@@ -245,10 +82,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",
|
||||
@@ -296,10 +133,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",
|
||||
@@ -348,10 +185,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",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Webhook" ADD COLUMN "secret" TEXT;
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Survey" ADD COLUMN "isCaptureIpEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -51,7 +51,6 @@ model Webhook {
|
||||
environmentId String
|
||||
triggers PipelineTriggers[]
|
||||
surveyIds String[]
|
||||
secret String?
|
||||
|
||||
@@index([environmentId])
|
||||
}
|
||||
@@ -386,7 +385,6 @@ model Survey {
|
||||
isVerifyEmailEnabled Boolean @default(false)
|
||||
isSingleResponsePerEmailEnabled Boolean @default(false)
|
||||
isBackButtonHidden Boolean @default(false)
|
||||
isCaptureIpEnabled Boolean @default(false)
|
||||
pin String?
|
||||
displayPercentage Decimal?
|
||||
languages SurveyLanguage[]
|
||||
|
||||
@@ -39,9 +39,6 @@ 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({
|
||||
|
||||
@@ -22,8 +22,8 @@ checksums:
|
||||
common/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
common/respondents_will_not_see_this_card: 18c3dd44d6ff6ca2310ad196b84f30d3
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/retrying: 0cb623dbdcbf16d3680f0180ceac734c
|
||||
common/sending_responses: 184772f70cca69424eaf34f73520789f
|
||||
common/retrying: 40989361ea5f6b95897b95ac928b5bd9
|
||||
common/sending_responses: 244f1aebc3f6a101ae2f8b630d7967ec
|
||||
common/takes_less_than_x_minutes: 1208ce0d4c0a679c11c7bd209b6ccc47
|
||||
common/takes_x_minutes: 001d12366d07b406f50669e761d63e69
|
||||
common/takes_x_plus_minutes: 145b8f287de140e98f492c8db2f9fa0b
|
||||
@@ -39,7 +39,7 @@ checksums:
|
||||
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
|
||||
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
|
||||
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
||||
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
|
||||
errors/invalid_device_error/title: ea7dbb9970c717e4d466f8e1211bd461
|
||||
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
|
||||
errors/please_enter_a_valid_email_address: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
|
||||
@@ -50,4 +50,4 @@ checksums:
|
||||
errors/please_select_an_option: 9fede3bb9ded29301e89b98616e3583a
|
||||
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
|
||||
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
|
||||
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
|
||||
errors/recaptcha_error/title: eb8f1106e0b4cb6756c5a76fd9400e67
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"required": "Required",
|
||||
"respondents_will_not_see_this_card": "Respondents will not see this card",
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying...",
|
||||
"sending_responses": "Sending responses...",
|
||||
"retrying": "Retrying…",
|
||||
"sending_responses": "Sending responses…",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
|
||||
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
|
||||
"takes_x_plus_minutes": "Takes {count}+ minutes",
|
||||
@@ -43,7 +43,7 @@
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
||||
"title": "This device doesn’t support spam protection."
|
||||
"title": "This device does not support spam protection."
|
||||
},
|
||||
"please_book_an_appointment": "Please book an appointment",
|
||||
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
||||
@@ -56,7 +56,7 @@
|
||||
"please_upload_a_file": "Please upload a file",
|
||||
"recaptcha_error": {
|
||||
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
|
||||
"title": "We couldn't verify that you're human."
|
||||
"title": "We could not verify that you are human."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,8 @@ import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "../../lib/i18n.config";
|
||||
|
||||
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
|
||||
// Set language synchronously on initial render so children get the correct translations immediately.
|
||||
// This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
|
||||
// Handle language prop changes after initial render
|
||||
useEffect(() => {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
i18n.changeLanguage(language);
|
||||
}, [language]);
|
||||
|
||||
// work around for react-i18next not supporting preact
|
||||
|
||||
@@ -309,7 +309,6 @@ export const ZResponseMeta = z.object({
|
||||
.optional(),
|
||||
country: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TResponseMeta = z.infer<typeof ZResponseMeta>;
|
||||
@@ -362,7 +361,6 @@ export const ZResponseInput = z.object({
|
||||
.optional(),
|
||||
country: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -895,7 +895,6 @@ export const ZSurvey = z
|
||||
recaptcha: ZSurveyRecaptcha.nullable(),
|
||||
isSingleResponsePerEmailEnabled: z.boolean(),
|
||||
isBackButtonHidden: z.boolean(),
|
||||
isCaptureIpEnabled: z.boolean(),
|
||||
pin: z.string().length(4, { message: "PIN must be a four digit number" }).nullish(),
|
||||
displayPercentage: z.number().min(0.01).max(100).nullable(),
|
||||
languages: z.array(ZSurveyLanguage),
|
||||
|
||||
Reference in New Issue
Block a user