Compare commits

..

3 Commits

Author SHA1 Message Date
Cursor Agent bef8dae328 fix: keep language strings scoped to survey editor 2026-05-13 12:32:52 +00:00
Cursor Agent e7e751c1c5 test: cover language translation string extraction 2026-05-13 12:13:42 +00:00
Cursor Agent d1c9b8a5a3 fix: include missed language translation strings
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-13 12:09:47 +00:00
96 changed files with 2738 additions and 2628 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments
# S3 Storage is required for the file upload in serverless environments like Vercel
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
-78
View File
@@ -1,78 +0,0 @@
name: Accessibility issue
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
type: bug
labels: ["accessibility", "bug"]
body:
- type: markdown
attributes:
value: |
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
- type: textarea
id: summary
attributes:
label: Summary
description: What part of Formbricks is affected and what's wrong?
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open a survey with multiple languages
2. Press Tab repeatedly
3. Focus never lands on the language switcher
validations:
required: true
- type: input
id: wcag
attributes:
label: Related WCAG criterion (if known)
placeholder: "e.g. 2.1.1 Keyboard"
- type: dropdown
id: severity
attributes:
label: Severity
options:
- "Critical — blocks a user from completing a core task"
- "High — significant barrier with no easy workaround"
- "Medium — barrier with a workaround"
- "Low — minor friction"
validations:
required: true
- type: input
id: at
attributes:
label: Assistive technology
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
- type: input
id: browser
attributes:
label: Browser and OS
placeholder: "e.g. Firefox 138 on Windows 11"
- type: dropdown
id: environment
attributes:
label: Your Environment
options:
- Formbricks Cloud (app.formbricks.com)
- Self-hosted Formbricks
validations:
required: true
- type: textarea
id: other
attributes:
label: Other information (screenshots, recordings, axe output)
+1
View File
@@ -0,0 +1 @@
apps/web/.env
-48
View File
@@ -1,48 +0,0 @@
# Accessibility
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
## Standards
We aim to conform to:
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
- **Section 508** — for users in US public-sector contexts.
## Priorities
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
In both areas we focus on:
- Keyboard navigation with a clearly visible focus indicator
- Screen reader support through semantic HTML and correctly scoped ARIA
- Sufficient color and contrast
- Programmatically associated labels and announced status messages
## Supported Environments
- Latest two versions of Chrome, Firefox, Safari, and Edge
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
## Contributing
When contributing UI changes:
- Prefer semantic HTML over ARIA.
- Tab through your change end-to-end and confirm focus is visible at every stop.
- Label every control. Don't convey meaning by color alone.
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
## Reporting Accessibility Issues
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
@@ -106,20 +106,18 @@ export const ResponseCardModal = ({
</DialogDescription>
</VisuallyHidden>
<DialogBody>
<div className="my-3">
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</div>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</DialogBody>
<DialogFooter>
<Button
@@ -11,7 +11,6 @@ import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
@@ -48,8 +47,7 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/6">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -81,12 +79,9 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/6">
<TableCell className="w-1/4">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6">
<IdBadge id={response.id} />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -113,12 +113,9 @@ export const SurveyAnalysisCTA = ({
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const singleUseLinkParams = await refreshSingleUseId();
if (singleUseLinkParams) {
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
}
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
@@ -2,7 +2,7 @@
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -41,7 +41,6 @@ export const AnonymousLinksTab = ({
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
const [customSingleUseId, setCustomSingleUseId] = useState("");
const [disableLinkModal, setDisableLinkModal] = useState<{
open: boolean;
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
pendingAction: () => Promise<void> | void;
} | null>(null);
const surveyUrlWithCustomSuid = useMemo(() => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", "CUSTOM-ID");
return url.toString();
}, [surveyUrl]);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
@@ -176,13 +181,10 @@ export const AnonymousLinksTab = ({
});
if (!!response?.data?.length) {
const singleUseLinkParams = response.data;
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", suId);
if (suToken) {
url.searchParams.set("suToken", suToken);
}
url.searchParams.set("suId", singleUseId);
return url.toString();
});
@@ -210,40 +212,6 @@ export const AnonymousLinksTab = ({
}
};
const handleCopyCustomSingleUseLink = async () => {
const trimmedCustomSingleUseId = customSingleUseId.trim();
if (!trimmedCustomSingleUseId) {
toast.error(t("environments.surveys.share.anonymous_links.custom_single_use_id_required"));
return;
}
try {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: false,
count: 1,
singleUseId: trimmedCustomSingleUseId,
});
const singleUseLinkParams = response?.data?.[0];
if (!singleUseLinkParams) {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
return;
}
const url = new URL(surveyUrl);
url.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
url.searchParams.set("suToken", singleUseLinkParams.suToken);
}
await navigator.clipboard.writeText(url.toString());
toast.success(t("common.copied_to_clipboard"));
} catch {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
}
};
return (
<>
<div className="flex h-full flex-col justify-between space-y-4">
@@ -311,19 +279,16 @@ export const AnonymousLinksTab = ({
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<Input
className="col-span-5 bg-white focus:border focus:border-slate-900"
value={customSingleUseId}
onChange={(event) => setCustomSingleUseId(event.target.value)}
placeholder={t(
"environments.surveys.share.anonymous_links.custom_single_use_id_placeholder"
)}
/>
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Button
variant="secondary"
disabled={!customSingleUseId.trim()}
onClick={handleCopyCustomSingleUseLink}
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
@@ -1,13 +0,0 @@
import { Prisma } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
};
@@ -1,116 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
type TSingleUseResponseInput = Pick<TResponseInput, "singleUseId" | "meta">;
type TValidateSingleUseResponseInputResult = { singleUseId: string } | { response: Response } | null;
export const validateSingleUseResponseInput = (
survey: TSurvey,
environmentId: string,
responseInput: TSingleUseResponseInput
): TValidateSingleUseResponseInputResult => {
if (survey.type !== "link" || !survey.singleUse?.enabled) {
return null;
}
if (!ENCRYPTION_KEY) {
logger.error({ surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
if (!responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInput.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
const suToken = url.searchParams.get("suToken");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let canonicalSingleUseId: string | null = null;
try {
canonicalSingleUseId = validateSurveySingleUseLinkParams({
surveyId: survey.id,
suId,
suToken,
isEncrypted: survey.singleUse.isEncrypted,
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
});
} catch (error) {
logger.error({ error, surveyId: survey.id, environmentId }, "Failed to validate single-use id");
}
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
return { singleUseId: canonicalSingleUseId };
};
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
@@ -83,7 +78,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -183,17 +177,6 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
@@ -41,10 +41,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -61,12 +61,6 @@ export const POST = withV1ApiWrapper({
return {
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
};
} else if (error instanceof InvalidInputError) {
return {
response: responses.forbiddenResponse(error.message, true, {
surveyId: inputValidation.data.surveyId,
}),
};
} else {
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return {
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -9,8 +9,6 @@ import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
vi.mock("server-only", () => ({}));
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
@@ -139,16 +137,6 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
});
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,14 +2,10 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[environmentId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
@@ -124,11 +120,7 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -2,15 +2,16 @@ import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -95,7 +96,10 @@ export const POST = withV1ApiWrapper({
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
@@ -125,24 +129,112 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.status !== "inProgress") {
return {
response: responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
}),
};
}
const singleUseValidationResult = validateSingleUseResponseInput(
survey,
environmentId,
responseInputData
);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
return { response: singleUseValidationResult.response };
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInputData.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInputData.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (survey.singleUse.isEncrypted) {
if (!ENCRYPTION_KEY) {
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
let decryptedSuId: string;
try {
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
} catch {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (decryptedSuId !== responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
} else if (responseInputData.singleUseId !== suId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
@@ -186,10 +278,6 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message, undefined, true),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
@@ -3,7 +3,7 @@ import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = withV1ApiWrapper({
@@ -56,22 +56,13 @@ export const GET = withV1ApiWrapper({
};
}
const singleUseLinkParams = generateSurveySingleUseLinkParamsList(
limit,
survey.id,
survey.singleUse.isEncrypted
);
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const surveyLink = new URL(`${publicDomain}/s/${survey.id}`);
surveyLink.searchParams.set("suId", suId);
if (suToken) {
surveyLink.searchParams.set("suToken", suToken);
}
return surveyLink.toString();
});
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return {
response: responses.successResponse(surveyLinks),
@@ -1,12 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
@@ -71,7 +66,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -155,17 +149,6 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
@@ -26,10 +26,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromEnvironmentId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,9 @@
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { applyClientApiRateLimit } from "@/app/lib/api/client-rate-limit";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -52,11 +51,6 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const rateLimitResponse = await applyClientApiRateLimit({ request, environmentId: params.environmentId });
if (rateLimitResponse) {
return rateLimitResponse;
}
const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
if ("response" in validatedInput) {
@@ -85,12 +79,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
}
if (error instanceof InvalidInputError) {
return responses.forbiddenResponse(error.message, true, {
surveyId: displayInputData.surveyId,
});
}
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
@@ -0,0 +1,135 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getEnvironmentState: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[environmentId]/environment/lib/environmentState", () => ({
getEnvironmentState: mocks.getEnvironmentState,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getEnvironmentState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("./route");
const response = await GET(request, {
params: Promise.resolve({
environmentId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -6,10 +6,6 @@ import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@fo
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[environmentId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -132,9 +128,12 @@ export const createResponse = async (
return response;
} catch (error) {
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
@@ -9,7 +9,6 @@ import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { generateSurveySingleUseSignature } from "@/lib/utils/single-use-surveys";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
vi.mock("@/lib/i18n/utils", () => ({
@@ -26,7 +25,6 @@ vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })),
notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })),
forbiddenResponse: vi.fn((message) => new Response(message, { status: 403 })),
},
}));
@@ -54,11 +52,6 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/constants", () => ({
ENCRYPTION_KEY: "test-key",
}));
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "test-key",
},
}));
const mockSurvey: TSurvey = {
id: "survey-1",
@@ -97,7 +90,6 @@ const mockSurvey: TSurvey = {
showLanguageSwitch: false,
blocks: [],
isCaptureIpEnabled: false,
isAutoProgressingEnabled: false,
metadata: {},
slug: null,
};
@@ -119,7 +111,6 @@ const mockBillingData: TOrganizationBilling = {
usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id",
};
const validSingleUseId = "cm8f4x9mm0001gx9h5b7d7h3q";
describe("checkSurveyValidity", () => {
beforeEach(() => {
@@ -139,19 +130,6 @@ describe("checkSurveyValidity", () => {
);
});
test.each(["draft", "paused", "completed"] as const)(
"should return forbiddenResponse when survey status is %s",
async (status) => {
const survey = { ...mockSurvey, status } as TSurvey;
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith("Survey is not accepting submissions", true, {
surveyId: mockSurvey.id,
});
}
);
test("should return null if recaptcha is not enabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 } };
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
@@ -244,14 +222,10 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
@@ -263,14 +237,10 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if meta.url is invalid", async () => {
@@ -284,8 +254,7 @@ describe("checkSurveyValidity", () => {
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid URL in response metadata",
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" }),
true
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
);
});
@@ -299,20 +268,16 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
@@ -321,20 +286,15 @@ describe("checkSurveyValidity", () => {
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
expect(resultEncryptedMismatch?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const suToken = generateSurveySingleUseSignature(survey.id, "su-2");
const url = `https://example.com/?suId=su-2&suToken=${suToken}`;
const url = "https://example.com/?suId=su-2";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
@@ -342,17 +302,13 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if not encrypted and suToken is missing", async () => {
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "env-1", {
@@ -360,39 +316,16 @@ describe("checkSurveyValidity", () => {
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return null if singleUse is enabled, not encrypted, and signed suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const suToken = generateSurveySingleUseSignature(survey.id, "su-1");
const url = `https://example.com/?suId=su-1&suToken=${suToken}`;
const responseInput = {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
};
const result = await checkSurveyValidity(survey, "env-1", responseInput);
expect(result).toBeNull();
expect(responseInput.singleUseId).toBe("su-1");
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: validSingleUseId,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
@@ -1,10 +1,11 @@
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -19,18 +20,53 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
}
if (survey.status !== "inProgress") {
return responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
});
}
const singleUseValidationResult = validateSingleUseResponseInput(survey, environmentId, responseInput);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
return singleUseValidationResult.response;
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (!responseInput.meta?.url) {
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
});
}
let url;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return responses.badRequestResponse("Invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
});
}
const suId = url.searchParams.get("suId");
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (survey.singleUse.isEncrypted) {
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
responseInput.singleUseId = singleUseValidationResult.singleUseId;
}
if (survey.recaptcha?.enabled) {
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});
@@ -4,7 +4,6 @@ import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/erro
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { applyClientApiRateLimit } from "@/app/lib/api/client-rate-limit";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -36,7 +35,10 @@ type TValidatedResponseInputResult =
| { response: Response };
const getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
@@ -201,11 +203,6 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const rateLimitResponse = await applyClientApiRateLimit({ request, environmentId: params.environmentId });
if (rateLimitResponse) {
return rateLimitResponse;
}
const validatedInput = await parseAndValidateResponseInput(request, params.environmentId);
if ("response" in validatedInput) {
@@ -1,107 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TooManyRequestsError } from "@formbricks/types/errors";
const mocks = vi.hoisted(() => ({
applyClientRateLimit: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyClientRateLimit: mocks.applyClientRateLimit,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "ck12345678901234567890123";
describe("client-rate-limit", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyClientRateLimit.mockResolvedValue({ allowed: true });
});
test("applies the client rate limit for a valid environment ID", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`);
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId });
expect(response).toBeNull();
expect(mocks.applyClientRateLimit).toHaveBeenCalledWith(environmentId, undefined);
});
test("passes custom configs to the client rate limit helper", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/storage`);
const customRateLimitConfig = {
interval: 60,
allowedPerInterval: 5,
namespace: "storage:upload",
};
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({
request,
environmentId,
customRateLimitConfig,
});
expect(response).toBeNull();
expect(mocks.applyClientRateLimit).toHaveBeenCalledWith(environmentId, customRateLimitConfig);
});
test("rejects invalid environment IDs before touching Redis", async () => {
const request = new Request("https://api.test/api/v2/client/not-a-cuid/displays");
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId: "not-a-cuid" });
expect(response?.status).toBe(400);
expect(await response?.json()).toEqual({
code: "bad_request",
message: "Invalid environment ID format",
details: {},
});
expect(mocks.applyClientRateLimit).not.toHaveBeenCalled();
});
test("returns 429 for TooManyRequestsError without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`);
mocks.applyClientRateLimit.mockRejectedValue(
new TooManyRequestsError("Maximum number of requests reached. Please try again later.")
);
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId });
expect(response?.status).toBe(429);
expect(await response?.json()).toEqual({
code: "too_many_requests",
message: "Maximum number of requests reached. Please try again later.",
details: {},
});
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("returns a generic 500 and reports unexpected rate limit failures", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`);
const underlyingError = new Error("Failed to hash IP");
mocks.applyClientRateLimit.mockRejectedValue(underlyingError);
const { applyClientApiRateLimit } = await import("./client-rate-limit");
const response = await applyClientApiRateLimit({ request, environmentId });
expect(response?.status).toBe(500);
expect(await response?.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
});
-70
View File
@@ -1,70 +0,0 @@
import { ZEnvironmentId } from "@formbricks/types/environment";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { responses } from "@/app/lib/api/response";
import { applyClientRateLimit } from "@/modules/core/rate-limit/helpers";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
const rateLimitMessage = "Maximum number of requests reached. Please try again later.";
const unexpectedErrorMessage = "Something went wrong. Please try again.";
export const validateClientEnvironmentId = (environmentId: string): string | null => {
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
return environmentIdValidation.success ? environmentIdValidation.data : null;
};
export const getInvalidClientEnvironmentIdResponse = (cors = true): Response => {
return responses.badRequestResponse("Invalid environment ID format", undefined, cors);
};
export const isTooManyRequestsError = (error: unknown): boolean => {
return (
error instanceof TooManyRequestsError || (error instanceof Error && error.name === "TooManyRequestsError")
);
};
export const getRateLimitErrorResponse = ({
request,
error,
cors = true,
}: {
request: Request;
error: unknown;
cors?: boolean;
}): Response => {
if (isTooManyRequestsError(error)) {
return responses.tooManyRequestsResponse(rateLimitMessage, cors);
}
const response = responses.internalServerErrorResponse(unexpectedErrorMessage, cors);
reportApiError({
request,
status: response.status,
error,
});
return response;
};
export const applyClientApiRateLimit = async ({
request,
environmentId,
customRateLimitConfig,
cors = true,
}: {
request: Request;
environmentId: string;
customRateLimitConfig?: TRateLimitConfig;
cors?: boolean;
}): Promise<Response | null> => {
const validEnvironmentId = validateClientEnvironmentId(environmentId);
if (!validEnvironmentId) {
return getInvalidClientEnvironmentIdResponse(cors);
}
try {
await applyClientRateLimit(validEnvironmentId, customRateLimitConfig);
return null;
} catch (error) {
return getRateLimitErrorResponse({ request, error, cors });
}
};
+7 -134
View File
@@ -3,7 +3,6 @@ import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
@@ -61,7 +60,7 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyClientRateLimit: vi.fn(),
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
}));
@@ -69,7 +68,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
clientEnvironment: { windowMs: 60000, max: 1000 },
v1: { windowMs: 60000, max: 1000 },
},
},
@@ -446,7 +444,7 @@ describe("withV1ApiWrapper", () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
@@ -455,19 +453,18 @@ describe("withV1ApiWrapper", () => {
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyClientRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/ck12345678901234567890123/displays" });
const req = createMockRequest({ url: "/api/v1/client/displays" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyClientRateLimit).toHaveBeenCalledWith("ck12345678901234567890123", undefined);
expect(handler).toHaveBeenCalledWith({
req,
props: undefined,
@@ -476,81 +473,6 @@ describe("withV1ApiWrapper", () => {
});
});
test("passes custom client rate limit config with the environment ID", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyClientRateLimit).mockResolvedValue({ allowed: true });
const customRateLimitConfig = {
interval: 60,
allowedPerInterval: 5,
namespace: "storage:upload",
};
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/ck12345678901234567890123/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyClientRateLimit).toHaveBeenCalledWith("ck12345678901234567890123", customRateLimitConfig);
expect(handler).toHaveBeenCalled();
});
test("rejects invalid client environment IDs before rate limiting", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyClientRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/not-a-cuid/displays" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
code: "bad_request",
message: "Invalid environment ID format",
details: {},
});
expect(applyClientRateLimit).not.toHaveBeenCalled();
expect(handler).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
{
pathname: "/api/v1/client/not-a-cuid/displays",
environmentId: "not-a-cuid",
},
"Invalid client API environment ID for rate limiting"
);
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -619,9 +541,9 @@ describe("withV1ApiWrapper", () => {
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(applyRateLimit).mockRejectedValue(
new TooManyRequestsError("Maximum number of requests reached. Please try again later.")
);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
@@ -633,55 +555,6 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("returns a generic error for unexpected client rate limit failures", async () => {
const { applyClientRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const underlyingError = new Error("Failed to hash IP");
vi.mocked(applyClientRateLimit).mockRejectedValue(underlyingError);
const handler = vi.fn();
const req = createMockRequest({
url: "/api/v1/client/ck12345678901234567890123/displays",
headers: new Map([["x-request-id", "rate-limit-failure"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(handler).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
correlationId: "rate-limit-failure",
path: "/api/v1/client/ck12345678901234567890123/displays",
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
status: 500,
}),
}),
})
);
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
+9 -46
View File
@@ -4,12 +4,6 @@ import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import {
applyClientApiRateLimit,
getInvalidClientEnvironmentIdResponse,
getRateLimitErrorResponse,
validateClientEnvironmentId,
} from "@/app/lib/api/client-rate-limit";
import { responses } from "@/app/lib/api/response";
import {
AuthenticationMethod,
@@ -19,7 +13,7 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
@@ -65,11 +59,11 @@ enum ApiV1RouteTypeEnum {
Integration = "integration",
}
const clientEnvironmentPathRegex = /^\/api\/v\d+\/client\/([^/]+)/;
const getClientEnvironmentIdFromPathname = (pathname: string): string | null => {
const match = clientEnvironmentPathRegex.exec(pathname);
return match?.[1] ?? null;
/**
* Apply client-side API rate limiting (IP-based)
*/
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
};
/**
@@ -78,11 +72,8 @@ const getClientEnvironmentIdFromPathname = (pathname: string): string | null =>
const handleRateLimiting = async (
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
req: NextRequest,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => {
const pathname = req.nextUrl.pathname;
try {
if (authentication) {
if ("user" in authentication) {
@@ -98,33 +89,10 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
const environmentIdFromPath = getClientEnvironmentIdFromPathname(pathname);
if (!environmentIdFromPath) {
logger.error({ pathname }, "Unable to determine client API environment ID for rate limiting");
return responses.badRequestResponse("Environment ID is required", undefined, true);
}
const validEnvironmentId = validateClientEnvironmentId(environmentIdFromPath);
if (!validEnvironmentId) {
logger.warn(
{ pathname, environmentId: environmentIdFromPath },
"Invalid client API environment ID for rate limiting"
);
return getInvalidClientEnvironmentIdResponse();
}
return await applyClientApiRateLimit({
request: req,
environmentId: validEnvironmentId,
customRateLimitConfig,
});
await applyClientRateLimit(customRateLimitConfig);
}
} catch (error) {
return getRateLimitErrorResponse({
request: req,
error,
cors: routeType === ApiV1RouteTypeEnum.Client,
});
return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded");
}
return null;
@@ -331,12 +299,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(
authentication,
routeType,
req,
customRateLimitConfig
);
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
if (rateLimitResponse) return rateLimitResponse;
}
+12 -19
View File
@@ -63,8 +63,8 @@ checksums:
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
auth/signup/product_updates_description: 64c458f1da8d0a1ab921070e2b4867bd
auth/signup/product_updates_title: e59c8ec06ec05b253f766a73653fdc98
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
@@ -214,9 +214,6 @@ checksums:
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/field_placeholder: ec26d96643d86da164162204ec6c650f
common/file_size_must_be_less_than_5_mb: a7d8ef9f888bfb3de2b589947fa2a63f
common/file_storage_not_set_up: ed82fc9c54da2f16245eaea9f8aa08f3
common/file_upload_service_unavailable: 93a6a904cef89cc18d2c4a65e2d581cc
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
@@ -435,6 +432,7 @@ checksums:
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
common/type: f04471a7ddac844b9ad145eb9911ef75
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
common/update: 079fc039262fd31b10532929685c2d1b
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
@@ -679,7 +677,6 @@ checksums:
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
environments/contacts/survey_response_created: e4a6eaac2acd8defca3ff57eae045ba6
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
@@ -1284,7 +1281,7 @@ checksums:
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
environments/surveys/edit/adjust_theme_in_look_and_feel_settings: 51372cb5ee8d3d42389bc95468866ad1
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
environments/surveys/edit/all_other_answers_will_continue_to_fallback: 81841a9911236672ed262e520c24e821
environments/surveys/edit/all_other_answers_will_continue_to_fallback: 4c0a7ca79f7f59e523803df375f01825
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
@@ -1299,10 +1296,10 @@ checksums:
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
environments/surveys/edit/automatically_close_survey_after_n_seconds_if_no_response: 7f8ea038a731a792f744d79fabba4aa9
environments/surveys/edit/automatically_close_survey_after_n_seconds_if_no_response: 3c816c2fa92dd46a8d2ac1a8efb5b17c
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
environments/surveys/edit/automatically_mark_complete_after_n_responses: b9145087d7a01b261dc03204347f118c
environments/surveys/edit/automatically_mark_complete_after_n_responses: 36bd1ecef42ff2292f47f88f5b5cd3bc
environments/surveys/edit/back_button_label: 504551d78645d968fcee95e3dfa5586f
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
@@ -1522,7 +1519,7 @@ checksums:
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
environments/surveys/edit/limit_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
environments/surveys/edit/limit_upload_file_size_to_mb: 6f1e25f7488c195d55e1a0cedb6ee587
environments/surveys/edit/limit_upload_file_size_to_mb: 7bf7d8c9e5f3fade66c2651746856ab9
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
environments/surveys/edit/list: 94f13e7ef909a4de9db7abaa1f9f0b61
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
@@ -1667,7 +1664,7 @@ checksums:
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
environments/surveys/edit/show_survey_maximum_of_n_times: 8f298b567cdd1c31db9ad56fc0c985aa
environments/surveys/edit/show_survey_maximum_of_n_times: 7adce73c375fa89cf8268f2fdc02d36d
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
@@ -1778,8 +1775,8 @@ checksums:
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
environments/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
environments/surveys/edit/wait_n_days_before_showing_this_survey_again: 1fbe83d8aaf59846d779e4e23d7d168b
environments/surveys/edit/wait_n_seconds_before_showing_the_survey: 8ff15e96a2f4ef23117bcd6da1cabdae
environments/surveys/edit/wait_n_days_before_showing_this_survey_again: e83a6536a5bd9a1b13115d8bc34ba6cf
environments/surveys/edit/wait_n_seconds_before_showing_the_survey: 1e5ec00f0392e7640f3ce9f5a6c67e4f
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
@@ -1812,7 +1809,6 @@ checksums:
environments/surveys/responses/completed: 2dca9d2e4c0fe669801112a66d679f6d
environments/surveys/responses/country: 73581fc33a1e83e6a56db73558e7b5c6
environments/surveys/responses/decrement_quotas: 9ef63d3e9214c508eb040eb41c25a5c4
environments/surveys/responses/delete_response: d86cb6fe4af953cde58f12092962bca4
environments/surveys/responses/delete_response_confirmation: 83a43954ca60c8ef30b9683253a6f5ea
environments/surveys/responses/delete_response_quotas: 6719c90d8019000ca0a74586fb3b6a65
environments/surveys/responses/device: c009f849d689c745de9e38ad17644c7a
@@ -1837,10 +1833,8 @@ checksums:
environments/surveys/responses/this_response_is_in_progress: 7d785fcb597ea30466467084fd474904
environments/surveys/responses/zip_post_code: ab7dc45bd5f9e37930586e2db17e4304
environments/surveys/search_by_survey_name: 44cf2e6f8ba43d233fb33939431eba99
environments/surveys/share/anonymous_links/custom_single_use_id_description: 994c76257cc373388a3870caf9d85dc7
environments/surveys/share/anonymous_links/custom_single_use_id_placeholder: e089b0f1838164b2ef2f74076477d8f2
environments/surveys/share/anonymous_links/custom_single_use_id_required: 36f60e76ebed5af11661ac7bbadd9a2b
environments/surveys/share/anonymous_links/custom_single_use_id_title: 0a1b97ed6fd82d9b0f9f43750d45fbe3
environments/surveys/share/anonymous_links/custom_single_use_id_description: 53c6d2b6cf597115b1f5a280ce7e9cab
environments/surveys/share/anonymous_links/custom_single_use_id_title: 9d708fe4ced64ddedd307fb61827cd03
environments/surveys/share/anonymous_links/custom_start_point: 4ea6552b37339d17e02f3bce8c7e4125
environments/surveys/share/anonymous_links/data_prefilling: 82f0e31e90f1f2ca31361df9893e117c
environments/surveys/share/anonymous_links/description: d13534a22f135420651ebfd218a4f01b
@@ -2100,7 +2094,6 @@ checksums:
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
environments/workspace/general/delete_workspace_confirmation_name: 79a461e6b63dd8c281d9ce1b43bc6f49
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 1b6c0597fddc5b6604e3a204402ed35e
environments/workspace/general/delete_workspace_settings_description: 411ef100f167fc8fca64e833b6c0d030
environments/workspace/general/error_saving_workspace_information: e7b8022785619ef34de1fb1630b3c476
+2 -1
View File
@@ -10,7 +10,8 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
export const WEBAPP_URL = env.WEBAPP_URL?.trim() || "http://localhost:3000";
export const WEBAPP_URL =
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
// encryption keys
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
@@ -32,7 +32,6 @@ beforeEach(() => {
id: mockSurveyId,
name: "Test Survey",
environmentId: mockEnvironmentId,
status: "inProgress",
} as any);
});
+2
View File
@@ -235,6 +235,7 @@ const parsedEnv = createEnv({
TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
@@ -353,6 +354,7 @@ const parsedEnv = createEnv({
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
NODE_ENV: process.env.NODE_ENV,
+12 -1
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
const envMock = {
WEBAPP_URL: undefined as string | undefined,
VERCEL_URL: undefined as string | undefined,
PUBLIC_URL: undefined as string | undefined,
};
@@ -18,6 +19,7 @@ const loadGetPublicDomain = async () => {
describe("getPublicDomain", () => {
beforeEach(() => {
envMock.WEBAPP_URL = undefined;
envMock.VERCEL_URL = undefined;
envMock.PUBLIC_URL = undefined;
});
@@ -29,7 +31,16 @@ describe("getPublicDomain", () => {
expect(getPublicDomain()).toBe("https://app.formbricks.com");
});
test("falls back to localhost when WEBAPP_URL is not set", async () => {
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
envMock.WEBAPP_URL = " ";
envMock.VERCEL_URL = "preview.formbricks.com";
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
});
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("http://localhost:3000");
+11 -1
View File
@@ -2,7 +2,17 @@ import "server-only";
import { env } from "./env";
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
const WEBAPP_URL = configuredWebappUrl === "" ? "http://localhost:3000" : configuredWebappUrl;
const WEBAPP_URL = (() => {
if (configuredWebappUrl !== "") {
return configuredWebappUrl;
}
if (env.VERCEL_URL) {
return `https://${env.VERCEL_URL}`;
}
return "http://localhost:3000";
})();
/**
* Returns the public domain URL
+1 -90
View File
@@ -2,13 +2,7 @@ import * as cuid2 from "@paralleldrive/cuid2";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto";
import { env } from "@/lib/env";
import {
generateSurveySingleUseId,
generateSurveySingleUseIds,
generateSurveySingleUseLinkParams,
validateSurveySingleUseLinkParams,
validateSurveySingleUseSignature,
} from "./single-use-surveys";
import { generateSurveySingleUseId, generateSurveySingleUseIds } from "./single-use-surveys";
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
@@ -118,87 +112,4 @@ describe("Single Use Surveys", () => {
expect(createIdMock).not.toHaveBeenCalled();
});
});
describe("signed single-use links", () => {
beforeEach(() => {
vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key";
});
test("generates and validates signed custom single-use IDs", () => {
const params = generateSurveySingleUseLinkParams("survey-1", false, "CUSTOM-ID");
expect(params.suId).toBe("CUSTOM-ID");
expect(params.suToken).toBeDefined();
expect(validateSurveySingleUseSignature("survey-1", params.suId, params.suToken)).toBe(true);
expect(
validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: params.suId,
suToken: params.suToken,
isEncrypted: false,
decrypt: vi.fn(),
})
).toBe("CUSTOM-ID");
});
test("rejects tampered signed custom single-use IDs", () => {
const params = generateSurveySingleUseLinkParams("survey-1", false, "CUSTOM-ID");
expect(validateSurveySingleUseSignature("survey-2", params.suId, params.suToken)).toBe(false);
expect(validateSurveySingleUseSignature("survey-1", "OTHER-ID", params.suToken)).toBe(false);
expect(validateSurveySingleUseSignature("survey-1", params.suId, "invalid-token")).toBe(false);
expect(validateSurveySingleUseSignature("survey-1", params.suId)).toBe(false);
});
});
describe("validateSurveySingleUseLinkParams", () => {
test("returns decrypted CUID for encrypted single-use IDs", () => {
const decrypt = vi.fn().mockReturnValue("decrypted-cuid");
vi.mocked(cuid2.isCuid).mockReturnValueOnce(true);
const result = validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: "encrypted-cuid",
isEncrypted: true,
decrypt,
});
expect(result).toBe("decrypted-cuid");
expect(decrypt).toHaveBeenCalledWith("encrypted-cuid");
expect(cuid2.isCuid).toHaveBeenCalledWith("decrypted-cuid");
});
test("rejects encrypted single-use IDs that decrypt to invalid CUIDs", () => {
const decrypt = vi.fn().mockReturnValue("invalid-id");
vi.mocked(cuid2.isCuid).mockReturnValueOnce(false);
const result = validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: "encrypted-cuid",
isEncrypted: true,
decrypt,
});
expect(result).toBeNull();
expect(decrypt).toHaveBeenCalledWith("encrypted-cuid");
expect(cuid2.isCuid).toHaveBeenCalledWith("invalid-id");
});
test("rejects encrypted single-use IDs when decryption fails", () => {
const decrypt = vi.fn(() => {
throw new Error("Invalid encrypted payload");
});
const result = validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: "malformed-encrypted-cuid",
isEncrypted: true,
decrypt,
});
expect(result).toBeNull();
expect(decrypt).toHaveBeenCalledWith("malformed-encrypted-cuid");
expect(cuid2.isCuid).not.toHaveBeenCalled();
});
});
});
+1 -101
View File
@@ -1,23 +1,7 @@
import { createId, isCuid } from "@paralleldrive/cuid2";
import { createHmac, timingSafeEqual } from "node:crypto";
import { createId } from "@paralleldrive/cuid2";
import { symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
const SINGLE_USE_SIGNATURE_PAYLOAD_PREFIX = "formbricks.single-use.v1";
export type TSurveySingleUseLinkParams = {
suId: string;
suToken?: string;
};
const getSingleUseSigningKey = (): string => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return env.ENCRYPTION_KEY;
};
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = createId();
@@ -42,87 +26,3 @@ export const generateSurveySingleUseIds = (count: number, isEncrypted: boolean):
return singleUseIds;
};
export const generateSurveySingleUseSignature = (surveyId: string, singleUseId: string): string => {
const payload = `${SINGLE_USE_SIGNATURE_PAYLOAD_PREFIX}:${surveyId}:${singleUseId}`;
return createHmac("sha256", getSingleUseSigningKey()).update(payload).digest("hex");
};
export const validateSurveySingleUseSignature = (
surveyId: string,
singleUseId: string,
signature?: string | null
): boolean => {
if (!signature) {
return false;
}
const expectedSignature = generateSurveySingleUseSignature(surveyId, singleUseId);
const expected = Buffer.from(expectedSignature);
const received = Buffer.from(signature);
return expected.length === received.length && timingSafeEqual(expected, received);
};
export const generateSurveySingleUseLinkParams = (
surveyId: string,
isEncrypted: boolean,
singleUseId?: string
): TSurveySingleUseLinkParams => {
if (isEncrypted) {
return { suId: generateSurveySingleUseId(true) };
}
const suId = singleUseId?.trim() || generateSurveySingleUseId(false);
return {
suId,
suToken: generateSurveySingleUseSignature(surveyId, suId),
};
};
export const generateSurveySingleUseLinkParamsList = (
count: number,
surveyId: string,
isEncrypted: boolean
): TSurveySingleUseLinkParams[] => {
const singleUseLinkParams: TSurveySingleUseLinkParams[] = [];
for (let i = 0; i < count; i++) {
singleUseLinkParams.push(generateSurveySingleUseLinkParams(surveyId, isEncrypted));
}
return singleUseLinkParams;
};
export const validateSurveySingleUseLinkParams = ({
surveyId,
suId,
suToken,
isEncrypted,
decrypt,
}: {
surveyId: string;
suId?: string | null;
suToken?: string | null;
isEncrypted: boolean;
decrypt: (encryptedSingleUseId: string) => string;
}): string | null => {
const trimmedSuId = suId?.trim();
if (!trimmedSuId) {
return null;
}
if (isEncrypted) {
try {
const decryptedSingleUseId = decrypt(trimmedSuId);
return isCuid(decryptedSingleUseId) ? decryptedSingleUseId : null;
} catch {
return null;
}
}
return validateSurveySingleUseSignature(surveyId, trimmedSuId, suToken) ? trimmedSuId : null;
};
+10 -13
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
"privacy_policy": "Datenschutzerklärung",
"product_updates_description": "Ich möchte monatliche Produkt-Update-E-Mails von Formbricks erhalten. Es gilt die Datenschutzerklärung.",
"product_updates_title": "Monatliche Produkt-Update-E-Mails",
"product_updates_description": "Monatliche Produktneuigkeiten und Feature-Updates, es gilt die Datenschutzerklärung.",
"product_updates_title": "Produkt-Updates",
"security_updates_description": "Nur sicherheitsrelevante Informationen, es gilt die Datenschutzerklärung.",
"security_updates_title": "Sicherheits-Updates",
"terms_of_service": "Nutzungsbedingungen",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "Noch 1 Tag in deiner Testphase",
"try_again": "Versuch's nochmal",
"type": "Typ",
"unknown_survey": "Unbekannte Umfrage",
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
"update": "Aktualisierung",
"updated": "Aktualisiert",
@@ -715,7 +716,6 @@
"select_a_survey": "Wähle eine Umfrage aus",
"select_attribute": "Attribut auswählen",
"select_attribute_key": "Attributschlüssel auswählen",
"survey_response_created": "Antwort erstellt",
"survey_viewed": "Umfrage angesehen",
"survey_viewed_at": "Angesehen am",
"system_attributes": "Systemattribute",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
"automatically_close_survey_after_n_seconds_if_no_response": "Umfrage automatisch nach <autoCloseInput /> Sekunden nach dem Trigger schließen, wenn keine Antwort erfolgt.",
"automatically_close_survey_after_n_seconds_if_no_response": "Umfrage automatisch nach <autoCloseInput /> Sekunden nach dem Auslöser schließen, wenn keine Antwort erfolgt.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
"automatically_mark_complete_after_n_responses": "Umfrage automatisch nach <autoCompleteInput /> abgeschlossenen Antworten als abgeschlossen markieren.",
"automatically_mark_complete_after_n_responses": "Umfrage automatisch als abgeschlossen markieren nach <autoCompleteInput /> vollständigen Antworten.",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergrundgestaltung",
"block_duplicated": "Block dupliziert.",
@@ -1593,7 +1593,7 @@
"last_name": "Nachname",
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
"limit_upload_file_size_to_mb": "Upload-Dateigröße auf <fileSizeInput /> MB begrenzen",
"limit_upload_file_size_to_mb": "Datei-Upload-Größe auf <fileSizeInput /> MB begrenzen",
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
"list": "Liste",
"load_segment": "Segment laden",
@@ -1853,8 +1853,8 @@
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
"visible": "Sichtbar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
"wait_n_days_before_showing_this_survey_again": "Warte <daysInput /> oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dieser Umfrage.",
"wait_n_seconds_before_showing_the_survey": "Warte <delayInput /> Sekunden, bevor die Umfrage angezeigt wird.",
"wait_n_days_before_showing_this_survey_again": "Warte <daysInput /> oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dem Anzeigen dieser Umfrage.",
"wait_n_seconds_before_showing_the_survey": "Warte <delayInput /> Sekunden, bevor du die Umfrage anzeigst.",
"waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der workspace-weiten Abkühlphase interagiert.",
"welcome_message": "Willkommensnachricht",
@@ -1891,7 +1891,6 @@
"completed": "Erledigt ✅",
"country": "Land",
"decrement_quotas": "Alle Grenzwerte der Kontingente einschließlich dieser Antwort verringern",
"delete_response": "Antwort löschen",
"delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Tags, angehängter Dokumente und Antwort-Metadaten löschen.",
"delete_response_quotas": "Die Antwort ist Teil der Quoten für diese Umfrage. Wie möchten Sie die Quoten verwalten?",
"device": "Gerät",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Nach Umfragenamen suchen",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Erstelle eine lesbare Einmal-ID und kopiere einen signierten Link dafür.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Verwende eine benutzerdefinierte Einmal-ID in der URL.",
"custom_single_use_id_description": "Wenn du die Einmal-ID nicht verschlüsselst, funktioniert jeder Wert für “suid=...” für eine Antwort.",
"custom_single_use_id_title": "Sie können im URL beliebige Werte als Einmal-ID festlegen.",
"custom_start_point": "Benutzerdefinierter Startpunkt",
"data_prefilling": "Daten-Prefilling",
"description": "Antworten, die von diesen Links kommen, werden anonym",
+5 -8
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
"please_verify_captcha": "Please verify reCAPTCHA",
"privacy_policy": "Privacy Policy",
"product_updates_description": "I'd like to receive monthly product update emails from Formbricks. Privacy Policy applies.",
"product_updates_title": "Monthly product update emails",
"product_updates_description": "Monthly product news and feature updates, Privacy Policy applies.",
"product_updates_title": "Product updates",
"security_updates_description": "Security relevant information only, Privacy Policy applies.",
"security_updates_title": "Security updates",
"terms_of_service": "Terms of Service",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 day left in your trial",
"try_again": "Try again",
"type": "Type",
"unknown_survey": "Unknown survey",
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
"update": "Update",
"updated": "Updated",
@@ -715,7 +716,6 @@
"select_a_survey": "Select a survey",
"select_attribute": "Select Attribute",
"select_attribute_key": "Select attribute key",
"survey_response_created": "Response created",
"survey_viewed": "Survey viewed",
"survey_viewed_at": "Viewed At",
"system_attributes": "System Attributes",
@@ -1891,7 +1891,6 @@
"completed": "Completed ✅",
"country": "Country",
"decrement_quotas": "Decrement all limits of quotas including this response",
"delete_response": "Delete response",
"delete_response_confirmation": "This will delete the survey response, including all answers, tags, attached documents, and response metadata.",
"delete_response_quotas": "The response is part of quotas for this survey. How do you want to handle the quotas?",
"device": "Device",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Search by survey name",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_single_use_id_description": "If you do not encrypt single-use IDs, any value for “suid=…” works for one response.",
"custom_single_use_id_title": "You can set any value as single-use ID in the URL.",
"custom_start_point": "Custom start point",
"data_prefilling": "Data prefilling",
"description": "Responses coming from these links will be anonymous",
+8 -11
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
"privacy_policy": "Política de privacidad",
"product_updates_description": "Me gustaría recibir correos electrónicos mensuales con actualizaciones de producto de Formbricks. Se aplica la Política de Privacidad.",
"product_updates_title": "Correos electrónicos mensuales con actualizaciones de producto",
"product_updates_description": "Noticias mensuales del producto y actualizaciones de funciones, se aplica la política de privacidad.",
"product_updates_title": "Actualizaciones del producto",
"security_updates_description": "Solo información relevante sobre seguridad, se aplica la política de privacidad.",
"security_updates_title": "Actualizaciones de seguridad",
"terms_of_service": "Términos de servicio",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 día restante en tu prueba",
"try_again": "Intentar de nuevo",
"type": "Tipo",
"unknown_survey": "Encuesta desconocida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
"update": "Actualizar",
"updated": "Actualizado",
@@ -715,7 +716,6 @@
"select_a_survey": "Selecciona una encuesta",
"select_attribute": "Seleccionar atributo",
"select_attribute_key": "Seleccionar clave de atributo",
"survey_response_created": "Respuesta creada",
"survey_viewed": "Encuesta vista",
"survey_viewed_at": "Vista el",
"system_attributes": "Atributos del sistema",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
"all_are_true": "todas son verdaderas",
"all_other_answers_will_continue_to_fallback": "Todas las demás respuestas continuarán <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Todas las demás respuestas seguirán usando <fallbackSelect />",
"allow_multi_select": "Permitir selección múltiple",
"allow_multiple_files": "Permitir múltiples archivos",
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
@@ -1370,7 +1370,7 @@
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
"automatically_close_survey_after_n_seconds_if_no_response": "Cerrar automáticamente la encuesta después de <autoCloseInput /> segundos tras el disparador si no hay respuesta.",
"automatically_close_survey_after_n_seconds_if_no_response": "Cerrar automáticamente la encuesta después de <autoCloseInput /> segundos tras activarse si no hay respuesta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
"automatically_mark_complete_after_n_responses": "Marcar automáticamente la encuesta como completada después de <autoCompleteInput /> respuestas completadas.",
@@ -1853,7 +1853,7 @@
"visibility_and_recontact_description": "Controla cuándo puede aparecer esta encuesta y con qué frecuencia puede volver a aparecer.",
"visible": "Visible",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta",
"wait_n_days_before_showing_this_survey_again": "Esperar <daysInput /> o más días entre la última encuesta mostrada y la visualización de esta encuesta.",
"wait_n_days_before_showing_this_survey_again": "Esperar <daysInput /> o más días entre la última encuesta mostrada y esta encuesta.",
"wait_n_seconds_before_showing_the_survey": "Esperar <delayInput /> segundos antes de mostrar la encuesta.",
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
"waiting_time_across_surveys_description": "Para evitar la fatiga de encuestas, elige cómo interactúa esta encuesta con el periodo de espera general del espacio de trabajo.",
@@ -1891,7 +1891,6 @@
"completed": "Completado ✅",
"country": "País",
"decrement_quotas": "Reducir todos los límites de cuotas que incluyen esta respuesta",
"delete_response": "Eliminar respuesta",
"delete_response_confirmation": "Esto eliminará la respuesta de la encuesta, incluyendo todas las respuestas, etiquetas, documentos adjuntos y metadatos de respuesta.",
"delete_response_quotas": "La respuesta forma parte de cuotas para esta encuesta. ¿Cómo quieres gestionar las cuotas?",
"device": "Dispositivo",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Buscar por nombre de encuesta",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Crea un ID legible de un solo uso y copia un enlace firmado para él.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Usa un ID personalizado de un solo uso en la URL.",
"custom_single_use_id_description": "Si no cifras el ID de un solo uso, cualquier valor para “suid=...” funciona para una respuesta.",
"custom_single_use_id_title": "Puedes establecer cualquier valor como ID de uso único en la URL.",
"custom_start_point": "Punto de inicio personalizado",
"data_prefilling": "Prellenado de datos",
"description": "Las respuestas procedentes de estos enlaces serán anónimas",
+8 -11
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
"privacy_policy": "Politique de confidentialité",
"product_updates_description": "J'aimerais recevoir les e-mails mensuels de mise à jour produit de Formbricks. La Politique de confidentialité s'applique.",
"product_updates_title": "E-mails mensuels de mise à jour produit",
"product_updates_description": "Actualités mensuelles du produit et mises à jour des fonctionnalités, la politique de confidentialité s'applique.",
"product_updates_title": "Mises à jour du produit",
"security_updates_description": "Informations relatives à la sécurité uniquement, la politique de confidentialité s'applique.",
"security_updates_title": "Mises à jour de sécurité",
"terms_of_service": "Conditions d'utilisation",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 jour restant dans votre période d'essai",
"try_again": "Réessayer",
"type": "Type",
"unknown_survey": "Enquête inconnue",
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
"update": "Mise à jour",
"updated": "Mise à jour",
@@ -715,7 +716,6 @@
"select_a_survey": "Sélectionner une enquête",
"select_attribute": "Sélectionner un attribut",
"select_attribute_key": "Sélectionner une clé d'attribut",
"survey_response_created": "Réponse créée",
"survey_viewed": "Enquête consultée",
"survey_viewed_at": "Consultée le",
"system_attributes": "Attributs système",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
"automatically_close_survey_after_n_seconds_if_no_response": "Fermer automatiquement le sondage après <autoCloseInput /> secondes suivant le déclenchement si aucune réponse.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fermer automatiquement le sondage après <autoCloseInput /> secondes si aucune réponse n'est donnée après le déclenchement.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
"automatically_mark_complete_after_n_responses": "Marquer automatiquement le sondage comme terminé après <autoCompleteInput /> réponses complétées.",
"automatically_mark_complete_after_n_responses": "Marquer automatiquement le sondage comme terminé après <autoCompleteInput /> réponses complètes.",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style d'arrière-plan",
"block_duplicated": "Bloc dupliqué.",
@@ -1853,7 +1853,7 @@
"visibility_and_recontact_description": "Contrôlez quand cette enquête peut apparaître et à quelle fréquence elle peut réapparaître.",
"visible": "Visible",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Attendez quelques secondes après le déclencheur avant de montrer l'enquête.",
"wait_n_days_before_showing_this_survey_again": "Attendre <daysInput /> jour(s) ou plus entre le dernier sondage affiché et l'affichage de ce sondage.",
"wait_n_days_before_showing_this_survey_again": "Attendre <daysInput /> jours ou plus entre le dernier sondage affiché et l'affichage de celui-ci.",
"wait_n_seconds_before_showing_the_survey": "Attendre <delayInput /> secondes avant d'afficher le sondage.",
"waiting_time_across_surveys": "Période de refroidissement (entre les sondages)",
"waiting_time_across_surveys_description": "Pour éviter la fatigue liée aux sondages, choisissez comment ce sondage interagit avec la période de refroidissement globale de l'espace de travail.",
@@ -1891,7 +1891,6 @@
"completed": "Terminé ✅",
"country": "Pays",
"decrement_quotas": "Décrémentez toutes les limites des quotas y compris cette réponse",
"delete_response": "Supprimer la réponse",
"delete_response_confirmation": "Cela supprimera la réponse au sondage, y compris toutes les réponses, les étiquettes, les documents joints et les métadonnées de réponse.",
"delete_response_quotas": "La réponse fait partie des quotas pour ce sondage. Comment voulez-vous gérer les quotas ?",
"device": "Dispositif",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Recherche par nom d'enquête",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Créez un identifiant à usage unique lisible et copiez un lien signé pour celui-ci.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Utilisez un identifiant personnalisé à usage unique dans l'URL.",
"custom_single_use_id_description": "Si vous ne chiffrez pas l'ID à usage unique, n'importe quelle valeur pour “suid=...” fonctionne pour une réponse.",
"custom_single_use_id_title": "Vous pouvez définir n'importe quelle valeur comme identifiant à usage unique dans l'URL.",
"custom_start_point": "Point de départ personnalisé",
"data_prefilling": "Préremplissage des données",
"description": "Les réponses provenant de ces liens seront anonymes",
+21 -24
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Nagybetűk és kisbetűk vegyesen",
"please_verify_captcha": "Ellenőrizze a reCAPTCHA-t",
"privacy_policy": "Adatvédelmi irányelvek",
"product_updates_description": "Szeretnék havi termékfrissítési e-maileket kapni a Formbricks-től. Az Adatvédelmi Szabályzat alkalmazandó.",
"product_updates_title": "Havi termékfrissítési e-mailek",
"product_updates_description": "Havi termékhírek és funkciófrissítések, adatvédelmi irányelvek alkalmazása.",
"product_updates_title": "Termékfrissítések",
"security_updates_description": "Csak biztonságra vonatkozó információk, adatvédelmi irányelvek alkalmazása.",
"security_updates_title": "Biztonsági frissítések",
"terms_of_service": "Használati feltételek",
@@ -198,7 +198,7 @@
"created_by": "Létrehozta",
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"data_refreshed_successfully": "Az adatok sikeresen frissítve",
"data_refreshed_successfully": "Az adatok sikeresen frissítve lettek",
"date": "Dátum",
"days": "nap",
"default": "Alapértelmezett",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
"try_again": "Próbálja újra",
"type": "Típus",
"unknown_survey": "Ismeretlen kérdőív",
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
"update": "Frissítés",
"updated": "Frissítve",
@@ -715,7 +716,6 @@
"select_a_survey": "Kérdőív kiválasztása",
"select_attribute": "Attribútum kiválasztása",
"select_attribute_key": "Attribútum kulcsának kiválasztása",
"survey_response_created": "Válasz létrehozva",
"survey_viewed": "Kérdőív megtekintve",
"survey_viewed_at": "Megtekintve ekkor:",
"system_attributes": "Rendszerattribútumok",
@@ -836,8 +836,8 @@
},
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
"please_select_a_survey_error": "Válasszon kérdőívet",
"reconnect_button": "Újrakapcsolódás",
"reconnect_button_description": "Az integrációkapcsolata lejárt. Kapcsolódjon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button": "Újracsatlakozás",
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
"slack": {
@@ -1106,7 +1106,7 @@
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
"license_feature_whitelabel": "Fehér címkés e-mailek",
"license_features_table_access": "Hozzáférés",
"license_features_table_description": "A példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_disabled": "Letiltva",
"license_features_table_enabled": "Engedélyezve",
"license_features_table_feature": "Funkció",
@@ -1232,15 +1232,15 @@
"confirm_delete_my_account": "Saját fiók törlése",
"confirm_your_current_password_to_get_started": "Erősítse meg a jelenlegi jelszavát a kezdéshez.",
"delete_account": "Fiók törlése",
"delete_account_confirmation_required": "E-mailes megerősítés szükséges a fiókja törléséhez.",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Kétfaktoros hitelesítés letiltása",
"disable_two_factor_authentication_description": "Ha le kell tiltania a kétfaktoros hitelesítést, akkor azt javasoljuk, hogy engedélyezze újra, amint lehetséges.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Minden visszaszerzési kód pontosan egyszer használható a hitelesítő nélküli hozzáférés megszerzéséhez.",
"email_change_initiated": "Az e-mail-címe megváltoztatása iránti kérelme kezdeményezve lett.",
"email_confirmation_does_not_match": "Az e-mail-cím megerősítése nem egyezik.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
"google_sso_account_deletion_requires_setup": "Nem tudtuk megerősíteni a személyazonosságát az SSO-szolgáltatóval. Próbálja meg újra, vagy vegye fel a kapcsolatot az adminisztrátorral.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Elvesztett hozzáférés",
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
@@ -1251,8 +1251,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
"sso_reauthentication_failed": "Az SSO újrahitelesítése nem sikerült. Próbálja meg újra törölni a fiókt.",
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatóhoz. Ha a személyazonossága megerősítésre került, akkor a fiókja automatikusan törölve lesz.",
"sso_reauthentication_failed": "Az SSO újrahitelesítés nem sikerült. Próbáld meg újra törölni a fiókodat.",
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthat a személyazonosság-szolgáltatódhoz. Ha a személyazonosságod megerősítést nyer, a fiókod automatikusan törlődik.",
"two_factor_authentication": "Kétfaktoros hitelesítés",
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
"all_are_true": "az összes igaz",
"all_other_answers_will_continue_to_fallback": "Minden egyéb válasz továbbra is <fallbackSelect /> fog",
"all_other_answers_will_continue_to_fallback": "Minden más válasz továbbra is <fallbackSelect />",
"allow_multi_select": "Több választás engedélyezése",
"allow_multiple_files": "Több fájl engedélyezése",
"allow_users_to_select_more_than_one_image": "Lehetővé tétel a felhasználóknak, hogy egynél több képet válasszanak ki",
@@ -1373,7 +1373,7 @@
"automatically_close_survey_after_n_seconds_if_no_response": "A felmérés automatikus bezárása <autoCloseInput /> másodperc elteltével az aktiválás után, amennyiben nem érkezik válasz.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.",
"automatically_mark_complete_after_n_responses": "A felmérés automatikus teljesítettként való megjelölése <autoCompleteInput /> kitöltött válasz után.",
"automatically_mark_complete_after_n_responses": "A felmérés automatikus befejezettként való megjelölése <autoCompleteInput /> kitöltött válasz után.",
"back_button_label": "A „Vissza” gomb címkéje",
"background_styling": "Háttér stílusának beállítása",
"block_duplicated": "A blokk kettőzve.",
@@ -1554,7 +1554,7 @@
"hide_progress_bar": "Folyamatjelző elrejtése",
"hide_question_settings": "Kérdésbeállítások elrejtése",
"hostname": "Gépnév",
"if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ vagy egy részleges választ el nem küldenek.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
"image": "Kép",
@@ -1593,7 +1593,7 @@
"last_name": "Vezetéknév",
"let_people_upload_up_to_25_files_at_the_same_time": "Lehetővé tétel a személyek számára, hogy egyszerre legfeljebb 25 fájlt töltsenek fel.",
"limit_the_maximum_file_size": "A legnagyobb fájlméret korlátozása a feltöltéseknél.",
"limit_upload_file_size_to_mb": "A feltölthető fájlméret korlátozása <fileSizeInput /> MB-ra",
"limit_upload_file_size_to_mb": "A feltöltött fájlméret korlátozása <fileSizeInput /> MB-ra",
"link_survey_description": "Egy kérdőív oldalára mutató hivatkozás megosztása vagy a kérdőív beágyazása egy weboldalba vagy e-mailbe.",
"list": "Lista",
"load_segment": "Szakasz betöltése",
@@ -1853,8 +1853,8 @@
"visibility_and_recontact_description": "Annak vezérlése, hogy ez a kérdőív mikor jelenhet meg és milyen gyakran jelenhet meg újra.",
"visible": "Látható",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Várakozás néhány másodpercig az aktiválás után, mielőtt megjelenítené a kérdőívet",
"wait_n_days_before_showing_this_survey_again": "<daysInput /> vagy több nap eltelésének várakozása az utoljára megjelenített felmérés és ezen felmérés megjelenítése között.",
"wait_n_seconds_before_showing_the_survey": "<delayInput /> másodperc várakozása a felmérés megjelenítése előtt.",
"wait_n_days_before_showing_this_survey_again": "Várjon <daysInput /> vagy több napot az utol megjelenített felmérés és ezen felmérés megjelenítése között.",
"wait_n_seconds_before_showing_the_survey": "Várjon <delayInput /> másodpercet a felmérés megjelenítése előtt.",
"waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)",
"waiting_time_across_surveys_description": "A kérdőívekbe való belefáradás megakadályozásához válassza ki, hogy ez a kérdőív hogyan lép kölcsönhatásba a munkaterület-szintű várakozási időszakkal.",
"welcome_message": "Üdvözlő üzenet",
@@ -1891,7 +1891,6 @@
"completed": "Befejezve ✅",
"country": "Ország",
"decrement_quotas": "A kvóták összes korlátjának csökkentése, beleértve ezt a választ is",
"delete_response": "Válasz törlése",
"delete_response_confirmation": "Ez törölni fogja a kérdőívre adott választ, beleértve az összes választ, címkét, csatolt dokumentumot és a válasz metaadatait.",
"delete_response_quotas": "A válasz a kérdőív kvótáinak részét képezik. Hogyan szeretné kezelni a kvótákat?",
"device": "Eszköz",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Keresés kérdőívnév alapján",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Hozzon létre egy olvasható, egyszer használatos azonosítót, és másoljon ki egy aláírt linket hozzá.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Használjon egyedi, egyszer használatos azonosítót az URL-ben.",
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítókat, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
"custom_single_use_id_title": "Bármilyen értéket beállíthat egyszer használatos azonosítóként az URL-ben.",
"custom_start_point": "Egyéni kezdési pont",
"data_prefilling": "Adatok előre kitöltése",
"description": "Az ezekről a hivatkozásokról érkező válaszok névtelenek lesznek",
@@ -2213,7 +2210,7 @@
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
"delete_workspace": "Munkaterület törlése",
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
"delete_workspace_confirmation_name": "Adja meg a(z) {projectName} projektnevet a következő mezőben a munkaterület végleges törlésének megerősítéséhez:",
"delete_workspace_confirmation_name": "Adja meg a(z) {projectName} munkaterület nevét a következő mezőben a munkaterület végleges törlésének megerősítéséhez:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
+12 -15
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
"please_verify_captcha": "reCAPTCHAを認証してください",
"privacy_policy": "プライバシーポリシー",
"product_updates_description": "Formbricksから毎月の製品アップデートメールを受け取りたいです。プライバシーポリシーが適用されます。",
"product_updates_title": "毎月の製品アップデートメール",
"product_updates_description": "毎月の製品ニュースと機能アップデート、プライバシーポリシーが適用されます。",
"product_updates_title": "製品アップデート",
"security_updates_description": "セキュリティ関連情報のみ、プライバシーポリシーが適用されます。",
"security_updates_title": "セキュリティアップデート",
"terms_of_service": "利用規約",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "トライアル期間の残り1日",
"try_again": "もう一度お試しください",
"type": "種類",
"unknown_survey": "不明なフォーム",
"unlock_more_workspaces_with_a_higher_plan": "上位プランでより多くのワークスペースを利用できます。",
"update": "更新",
"updated": "更新済み",
@@ -715,7 +716,6 @@
"select_a_survey": "フォームを選択",
"select_attribute": "属性を選択",
"select_attribute_key": "属性キーを選択",
"survey_response_created": "回答が作成されました",
"survey_viewed": "フォームを閲覧",
"survey_viewed_at": "閲覧日時",
"system_attributes": "システム属性",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
"all_are_true": "すべてが真である",
"all_other_answers_will_continue_to_fallback": "その他のすべての回答は引き続き<fallbackSelect />されます",
"all_other_answers_will_continue_to_fallback": "その他の回答は引き続き<fallbackSelect />",
"allow_multi_select": "複数選択を許可",
"allow_multiple_files": "複数のファイルを許可",
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
"automatically_close_survey_after_n_seconds_if_no_response": "トリガー後、応がない場合は<autoCloseInput />秒後に自動的にアンケートを閉じます。",
"automatically_close_survey_after_n_seconds_if_no_response": "トリガー後、応がない場合は<autoCloseInput />秒後に自動的にアンケートを閉じます。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput />件の回答完了後、自動的にアンケートを完了としてマークします。",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput />件の回答完了した後、自動的にアンケートを完了としてマークします。",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル設定",
"block_duplicated": "ブロックが複製されました。",
@@ -1593,7 +1593,7 @@
"last_name": "姓",
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
"limit_upload_file_size_to_mb": "アップロードファイルサイズを<fileSizeInput /> MBに制限します",
"limit_upload_file_size_to_mb": "アップロードファイルサイズを<fileSizeInput /> MBに制限",
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
"list": "リスト",
"load_segment": "セグメントを読み込み",
@@ -1740,7 +1740,7 @@
"show_multiple_times": "限られた回数表示する",
"show_only_once": "一度だけ表示",
"show_question_settings": "質問設定を表示",
"show_survey_maximum_of_n_times": "アンケートを最大<displayLimitInput />回まで表示します。",
"show_survey_maximum_of_n_times": "アンケートの表示回数を最大<displayLimitInput />回に制限します。",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
@@ -1853,8 +1853,8 @@
"visibility_and_recontact_description": "このフォームがいつ表示され、どのくらいの頻度で再表示できるかをコントロールします。",
"visible": "表示",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "トリガーから数秒待ってからフォームを表示します",
"wait_n_days_before_showing_this_survey_again": "前回のアンケート表示からこのアンケートを表示するまで<daysInput />日以上待ちます。",
"wait_n_seconds_before_showing_the_survey": "アンケートを表示する前に<delayInput />秒待ます。",
"wait_n_days_before_showing_this_survey_again": "前回のアンケート表示から<daysInput />日以上経過してから、このアンケートを表示します。",
"wait_n_seconds_before_showing_the_survey": "アンケートを表示するまで<delayInput />秒待機します。",
"waiting_time_across_surveys": "クールダウン期間(アンケート全体)",
"waiting_time_across_surveys_description": "アンケート疲れを防ぐため、このアンケートがワークスペース全体のクールダウン期間とどのように連動するかを選択してください。",
"welcome_message": "ウェルカムメッセージ",
@@ -1891,7 +1891,6 @@
"completed": "完了 ✅",
"country": "国",
"decrement_quotas": "すべて の 制限 を 減少 し、 この 回答 を 含む しきい値",
"delete_response": "回答を削除",
"delete_response_confirmation": "これにより、すべての回答、タグ、添付されたドキュメント、および回答メタデータを含むフォームの回答が削除されます。",
"delete_response_quotas": "この回答は、このアンケートの割り当ての一部です。 割り当てをどのように処理しますか?",
"device": "デバイス",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "フォーム名で検索",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "読みやすいワンタイムIDを作成し、それに対応する署名付きリンクをコピーします。",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "URLにカスタムワンタイムIDを使用する。",
"custom_single_use_id_description": "シングルユースIDを暗号化しない場合、「suid=...」の任意の値で1回の回答が可能になります。",
"custom_single_use_id_title": "URLで任意の値を単一使用IDとして設定できます。",
"custom_start_point": "カスタム開始点",
"data_prefilling": "データの事前入力",
"description": "これらのリンクからの回答は匿名になります",
+9 -12
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
"please_verify_captcha": "Controleer reCAPTCHA",
"privacy_policy": "Privacybeleid",
"product_updates_description": "Ik ontvang graag maandelijkse productupdates per e-mail van Formbricks. Het Privacybeleid is van toepassing.",
"product_updates_title": "Maandelijkse productupdates per e-mail",
"product_updates_description": "Maandelijks productnieuws en feature-updates, privacybeleid is van toepassing.",
"product_updates_title": "Product-updates",
"security_updates_description": "Alleen beveiligingsrelevante informatie, privacybeleid is van toepassing.",
"security_updates_title": "Beveiligingsupdates",
"terms_of_service": "Servicevoorwaarden",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 dag over in je proefperiode",
"try_again": "Probeer het opnieuw",
"type": "Type",
"unknown_survey": "Onbekende enquête",
"unlock_more_workspaces_with_a_higher_plan": "Ontgrendel meer werkruimtes met een hoger abonnement.",
"update": "Update",
"updated": "Bijgewerkt",
@@ -715,7 +716,6 @@
"select_a_survey": "Selecteer een enquête",
"select_attribute": "Selecteer Kenmerk",
"select_attribute_key": "Selecteer kenmerksleutel",
"survey_response_created": "Antwoord aangemaakt",
"survey_viewed": "Enquête bekeken",
"survey_viewed_at": "Bekeken op",
"system_attributes": "Systeemkenmerken",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
"all_are_true": "alle zijn waar",
"all_other_answers_will_continue_to_fallback": "Alle andere antwoorden blijven <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Alle andere antwoorden zullen blijven <fallbackSelect />",
"allow_multi_select": "Multi-select toestaan",
"allow_multiple_files": "Meerdere bestanden toestaan",
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
@@ -1370,7 +1370,7 @@
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
"automatically_close_survey_after_n_seconds_if_no_response": "Sluit de enquête automatisch na <autoCloseInput /> seconden na activering als er geen reactie is.",
"automatically_close_survey_after_n_seconds_if_no_response": "Sluit de enquête automatisch na <autoCloseInput /> seconden na activatie als er geen reactie komt.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
"automatically_mark_complete_after_n_responses": "Markeer de enquête automatisch als voltooid na <autoCompleteInput /> voltooide reacties.",
@@ -1593,7 +1593,7 @@
"last_name": "Achternaam",
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
"limit_upload_file_size_to_mb": "Beperk de bestandsgrootte voor uploads tot <fileSizeInput /> MB",
"limit_upload_file_size_to_mb": "Beperk de uploadbestandsgrootte tot <fileSizeInput /> MB",
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
"list": "Lijst",
"load_segment": "Laadsegment",
@@ -1854,7 +1854,7 @@
"visible": "Zichtbaar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wacht een paar seconden na de trigger voordat u de enquête weergeeft",
"wait_n_days_before_showing_this_survey_again": "Wacht <daysInput /> of meer dagen tussen de laatst getoonde enquête en het tonen van deze enquête.",
"wait_n_seconds_before_showing_the_survey": "Wacht <delayInput /> seconden voordat de enquête wordt getoond.",
"wait_n_seconds_before_showing_the_survey": "Wacht <delayInput /> seconden voordat je de enquête toont.",
"waiting_time_across_surveys": "Afkoelperiode (voor alle enquêtes)",
"waiting_time_across_surveys_description": "Om enquêtemoeheid te voorkomen, kies hoe deze enquête omgaat met de workspace-brede afkoelperiode.",
"welcome_message": "Welkomstbericht",
@@ -1891,7 +1891,6 @@
"completed": "Voltooid ✅",
"country": "Land",
"decrement_quotas": "Verlaag alle limieten van quota, inclusief dit antwoord",
"delete_response": "Antwoord verwijderen",
"delete_response_confirmation": "Hierdoor wordt het enquêteantwoord verwijderd, inclusief alle antwoorden, tags, bijgevoegde documenten en metagegevens van het antwoord.",
"delete_response_quotas": "De respons maakt deel uit van de quota voor dit onderzoek. Hoe wilt u omgaan met de quota?",
"device": "Apparaat",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Zoek op enquêtenaam",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Maak een leesbare eenmalige ID aan en kopieer een ondertekende link hiervoor.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Gebruik een aangepaste eenmalige ID in de URL.",
"custom_single_use_id_description": "Als u de eenmalige ID niet versleutelt, werkt elke waarde voor “suid=...” voor één antwoord.",
"custom_single_use_id_title": "U kunt elke waarde instellen als ID voor eenmalig gebruik in de URL.",
"custom_start_point": "Aangepast startpunt",
"data_prefilling": "Gegevens vooraf invullen",
"description": "Reacties afkomstig van deze links zijn anoniem",
+9 -12
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
"privacy_policy": "Política de Privacidade",
"product_updates_description": "Gostaria de receber e-mails mensais com atualizações de produtos da Formbricks. A Política de Privacidade se aplica.",
"product_updates_title": "E-mails mensais de atualizações de produtos",
"product_updates_description": "Novidades mensais do produto e atualizações de recursos, a Política de Privacidade se aplica.",
"product_updates_title": "Atualizações do produto",
"security_updates_description": "Apenas informações relevantes sobre segurança, a Política de Privacidade se aplica.",
"security_updates_title": "Atualizações de segurança",
"terms_of_service": "Termos de Serviço",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 dia restante no seu período de teste",
"try_again": "Tenta de novo",
"type": "Tipo",
"unknown_survey": "Pesquisa desconhecida",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "atualizar",
"updated": "atualizado",
@@ -715,7 +716,6 @@
"select_a_survey": "Selecione uma pesquisa",
"select_attribute": "Selecionar Atributo",
"select_attribute_key": "Selecionar chave de atributo",
"survey_response_created": "Resposta criada",
"survey_viewed": "Pesquisa visualizada",
"survey_viewed_at": "Visualizada em",
"system_attributes": "Atributos do sistema",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar a pesquisa automaticamente após <autoCloseInput /> segundos depois do acionamento, caso não haja resposta.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente a pesquisa após <autoCloseInput /> segundos do acionamento se não houver resposta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
"automatically_mark_complete_after_n_responses": "Marcar a pesquisa como concluída automaticamente após <autoCompleteInput /> respostas completas.",
"automatically_mark_complete_after_n_responses": "Marcar automaticamente a pesquisa como concluída após <autoCompleteInput /> respostas completas.",
"back_button_label": "Voltar",
"background_styling": "Estilo do plano de fundo",
"block_duplicated": "Bloco duplicado.",
@@ -1740,7 +1740,7 @@
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_only_once": "Mostrar só uma vez",
"show_question_settings": "Mostrar configurações da pergunta",
"show_survey_maximum_of_n_times": "Exibir a pesquisa no máximo <displayLimitInput /> vezes.",
"show_survey_maximum_of_n_times": "Mostrar a pesquisa no máximo <displayLimitInput /> vezes.",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"shrink_preview": "Recolher prévia",
@@ -1853,7 +1853,7 @@
"visibility_and_recontact_description": "Controle quando esta pesquisa pode aparecer e com que frequência pode reaparecer.",
"visible": "Visível",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Espera alguns segundos depois do gatilho antes de mostrar a pesquisa",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre a última exibição e a próxima exibição desta pesquisa.",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre a última pesquisa exibida e a exibição desta pesquisa.",
"wait_n_seconds_before_showing_the_survey": "Aguardar <delayInput /> segundos antes de exibir a pesquisa.",
"waiting_time_across_surveys": "Período de espera (entre pesquisas)",
"waiting_time_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o período de espera geral do workspace.",
@@ -1891,7 +1891,6 @@
"completed": "Concluído ✅",
"country": "País",
"decrement_quotas": "Diminua todos os limites de cotas, incluindo esta resposta",
"delete_response": "Excluir resposta",
"delete_response_confirmation": "Isso irá excluir a resposta da pesquisa, incluindo todas as respostas, etiquetas, documentos anexados e metadados da resposta.",
"delete_response_quotas": "A resposta faz parte das cotas desta pesquisa. Como você quer gerenciar as cotas?",
"device": "dispositivo",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Buscar pelo nome da pesquisa",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Crie um ID de uso único legível e copie um link assinado para ele.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use um ID de uso único personalizado na URL.",
"custom_single_use_id_description": "Se você não criptografar o ID de uso único, qualquer valor para “suid=...” funciona para uma resposta.",
"custom_single_use_id_title": "Você pode definir qualquer valor como ID de uso único na URL.",
"custom_start_point": "Ponto de início personalizado",
"data_prefilling": "preenchimento automático de dados",
"description": "Respostas vindas desses links serão anônimas",
+8 -11
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
"privacy_policy": "Política de Privacidade",
"product_updates_description": "Gostaria de receber e-mails mensais com atualizações de produto da Formbricks. Aplica-se a Política de Privacidade.",
"product_updates_title": "E-mails mensais com atualizações de produto",
"product_updates_description": "Notícias mensais sobre o produto e atualizações de funcionalidades, aplica-se a Política de Privacidade.",
"product_updates_title": "Atualizações do produto",
"security_updates_description": "Apenas informações relevantes sobre segurança, aplica-se a Política de Privacidade.",
"security_updates_title": "Atualizações de segurança",
"terms_of_service": "Termos de Serviço",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 dia restante no teu período de teste",
"try_again": "Tente novamente",
"type": "Tipo",
"unknown_survey": "Inquérito desconhecido",
"unlock_more_workspaces_with_a_higher_plan": "Desbloqueie mais projetos com um plano superior.",
"update": "Atualizar",
"updated": "Atualizado",
@@ -715,7 +716,6 @@
"select_a_survey": "Selecione um inquérito",
"select_attribute": "Selecionar Atributo",
"select_attribute_key": "Selecionar chave de atributo",
"survey_response_created": "Resposta criada",
"survey_viewed": "Inquérito visualizado",
"survey_viewed_at": "Visualizado em",
"system_attributes": "Atributos do sistema",
@@ -1370,7 +1370,7 @@
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente o inquérito após <autoCloseInput /> segundos após o acionamento se não houver resposta.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente o inquérito após <autoCloseInput /> segundos depois do acionamento se não houver resposta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
"automatically_mark_complete_after_n_responses": "Marcar automaticamente o inquérito como concluído após <autoCompleteInput /> respostas completas.",
@@ -1740,7 +1740,7 @@
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_only_once": "Mostrar apenas uma vez",
"show_question_settings": "Mostrar definições da pergunta",
"show_survey_maximum_of_n_times": "Mostrar o inquérito no máximo <displayLimitInput /> vezes.",
"show_survey_maximum_of_n_times": "Mostrar inquérito no máximo <displayLimitInput /> vezes.",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"shrink_preview": "Reduzir pré-visualização",
@@ -1853,7 +1853,7 @@
"visibility_and_recontact_description": "Controlar quando este inquérito pode aparecer e com que frequência pode reaparecer.",
"visible": "Visível",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Aguarde alguns segundos após o gatilho antes de mostrar o inquérito",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre o último inquérito mostrado e a apresentação deste inquérito.",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre o último inquérito mostrado e a exibição deste inquérito.",
"wait_n_seconds_before_showing_the_survey": "Aguardar <delayInput /> segundos antes de mostrar o inquérito.",
"waiting_time_across_surveys": "Período de espera (entre inquéritos)",
"waiting_time_across_surveys_description": "Para prevenir fadiga de inquéritos, escolha como este inquérito interage com o período de espera geral do espaço de trabalho.",
@@ -1891,7 +1891,6 @@
"completed": "Concluído ✅",
"country": "País",
"decrement_quotas": "Decrementar todos os limites das cotas incluindo esta resposta",
"delete_response": "Eliminar resposta",
"delete_response_confirmation": "Isto irá apagar a resposta do inquérito, incluindo todas as respostas, etiquetas, documentos anexos e metadados da resposta.",
"delete_response_quotas": "A resposta faz parte das quotas deste inquérito. Como deseja gerir as quotas?",
"device": "Dispositivo",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Pesquisar por nome do inquérito",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Cria um ID de utilização única legível e copia uma ligação assinada para o mesmo.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Usa um ID de utilização única personalizado no URL.",
"custom_single_use_id_description": "Se não encriptar o ID de utilização única, qualquer valor para \"suid=...\" funciona para uma resposta.",
"custom_single_use_id_title": "Pode definir qualquer valor como ID de uso único no URL.",
"custom_start_point": "Ponto de início personalizado",
"data_prefilling": "Pré-preenchimento de dados",
"description": "Respostas provenientes destes links serão anónimas",
+10 -13
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
"privacy_policy": "Politica de confidențialitate",
"product_updates_description": "Aș dori să primesc lunar e-mailuri cu actualizări despre produs de la Formbricks. Se aplică Politica de confidențialitate.",
"product_updates_title": "E-mailuri lunare cu actualizări despre produs",
"product_updates_description": "Noutăți lunare despre produse și actualizări de funcționalități; se aplică Politica de confidențialitate.",
"product_updates_title": "Actualizări de produs",
"security_updates_description": "Doar informații relevante pentru securitate; se aplică Politica de confidențialitate.",
"security_updates_title": "Actualizări de securitate",
"terms_of_service": "Termeni de utilizare a serviciului",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 zi rămasă în perioada ta de probă",
"try_again": "Încearcă din nou",
"type": "Tip",
"unknown_survey": "Chestionar necunoscut",
"unlock_more_workspaces_with_a_higher_plan": "Deblochează mai multe workspaces cu un plan superior.",
"update": "Actualizare",
"updated": "Actualizat",
@@ -715,7 +716,6 @@
"select_a_survey": "Selectați un sondaj",
"select_attribute": "Selectează atributul",
"select_attribute_key": "Selectează cheia atributului",
"survey_response_created": "Răspuns creat",
"survey_viewed": "Chestionar vizualizat",
"survey_viewed_at": "Vizualizat la",
"system_attributes": "Atribute de sistem",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
"automatically_close_survey_after_n_seconds_if_no_response": "Închide automat chestionarul după <autoCloseInput /> secunde de la declanșare dacă nu există răspuns.",
"automatically_close_survey_after_n_seconds_if_no_response": "Închide automat sondajul după <autoCloseInput /> secunde de la declanșare dacă nu există răspuns.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
"automatically_mark_complete_after_n_responses": "Marchează automat chestionarul ca finalizat după <autoCompleteInput /> răspunsuri completate.",
"automatically_mark_complete_after_n_responses": "Marchează automat sondajul ca finalizat după <autoCompleteInput /> răspunsuri completate.",
"back_button_label": "Etichetă buton \"Înapoi\"",
"background_styling": "Stilizare fundal",
"block_duplicated": "Bloc duplicat.",
@@ -1740,7 +1740,7 @@
"show_multiple_times": "Afișează de mai multe ori",
"show_only_once": "Afișează doar o dată",
"show_question_settings": "Afișează setările întrebării",
"show_survey_maximum_of_n_times": "Afișează chestionarul maximum de <displayLimitInput /> ori.",
"show_survey_maximum_of_n_times": "Afișează sondajul maximum de <displayLimitInput /> ori.",
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
"shrink_preview": "Restrânge previzualizarea",
@@ -1853,8 +1853,8 @@
"visibility_and_recontact_description": "Controlează când poate apărea acest sondaj și cât de des poate reapărea.",
"visible": "Vizibil",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Așteptați câteva secunde după declanșare înainte de a afișa sondajul",
"wait_n_days_before_showing_this_survey_again": "Așteaptă <daysInput /> sau mai multe zile între ultimul chestionar afișat și afișarea acestui chestionar.",
"wait_n_seconds_before_showing_the_survey": "Așteaptă <delayInput /> secunde înainte de a afișa chestionarul.",
"wait_n_days_before_showing_this_survey_again": "Așteaptă <daysInput /> sau mai multe zile să treacă între ultimul sondaj afișat și afișarea acestui sondaj.",
"wait_n_seconds_before_showing_the_survey": "Așteaptă <delayInput /> secunde înainte de a afișa sondajul.",
"waiting_time_across_surveys": "Perioadă de răcire (între sondaje)",
"waiting_time_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interacționează acest sondaj cu perioada de răcire la nivel de workspace.",
"welcome_message": "Mesaj de bun venit",
@@ -1891,7 +1891,6 @@
"completed": "Finalizat ✅",
"country": "Țară",
"decrement_quotas": "Decrementați toate limitele cotelor, inclusiv acest răspuns",
"delete_response": "Șterge răspunsul",
"delete_response_confirmation": "Aceasta va șterge răspunsul la sondaj, inclusiv toate răspunsurile, etichetele, documentele atașate și metadatele răspunsului.",
"delete_response_quotas": "Răspunsul face parte din cotele pentru acest sondaj. Cum doriți să gestionați cotele?",
"device": "Dispozitiv",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Căutare după nume chestionar",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Creează un ID de unică folosință lizibil și copiază un link semnat pentru acesta.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Folosește un ID personalizat de unică folosință în URL.",
"custom_single_use_id_description": "Dacă nu criptați ID-ul de unică folosință, orice valoare pentru “suid=...” funcționează pentru un singur răspuns.",
"custom_single_use_id_title": "Puteți seta orice valoare ca ID unic în URL",
"custom_start_point": "Punct de start personalizat",
"data_prefilling": "Precompletare date",
"description": "Răspunsurile provenite de la aceste linkuri vor fi anonime",
+11 -14
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
"privacy_policy": "Политика конфиденциальности",
"product_updates_description": "Я хочу получать ежемесячные письма с обновлениями продукта от Formbricks. Действует Политика конфиденциальности.",
"product_updates_title": "Ежемесячные письма с обновлениями продукта",
"product_updates_description": "Ежемесячные новости о продукте и обновления функций. Применяется Политика конфиденциальности.",
"product_updates_title": "Обновления продукта",
"security_updates_description": "Только важная информация по безопасности. Применяется Политика конфиденциальности.",
"security_updates_title": "Обновления безопасности",
"terms_of_service": "Условия использования",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "Остался 1 день пробного периода",
"try_again": "Попробуйте ещё раз",
"type": "Тип",
"unknown_survey": "Неизвестный опрос",
"unlock_more_workspaces_with_a_higher_plan": "Откройте больше рабочих пространств с более высоким тарифом.",
"update": "Обновить",
"updated": "Обновлено",
@@ -715,7 +716,6 @@
"select_a_survey": "Выберите опрос",
"select_attribute": "Выберите атрибут",
"select_attribute_key": "Выберите ключ атрибута",
"survey_response_created": "Ответ создан",
"survey_viewed": "Опрос просмотрен",
"survey_viewed_at": "Просмотрено",
"system_attributes": "Системные атрибуты",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
"all_are_true": "все условия выполняются",
"all_other_answers_will_continue_to_fallback": "Все остальные ответы продолжат <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Все остальные ответы будут продолжать <fallbackSelect />",
"allow_multi_select": "Разрешить множественный выбор",
"allow_multiple_files": "Разрешить несколько файлов",
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
"automatically_close_survey_after_n_seconds_if_no_response": "Автоматически закрыть опрос через <autoCloseInput /> секунд после запуска, если нет ответа.",
"automatically_close_survey_after_n_seconds_if_no_response": "Автоматически закрывать опрос через <autoCloseInput /> секунд после срабатывания триггера, если нет ответа.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
"automatically_mark_complete_after_n_responses": "Автоматически отметить опрос как завершенный после <autoCompleteInput /> полученных ответов.",
"automatically_mark_complete_after_n_responses": "Автоматически отмечать опрос как завершённый после <autoCompleteInput /> полученных ответов.",
"back_button_label": "Метка кнопки «Назад»",
"background_styling": "Оформление фона",
"block_duplicated": "Блокировать дубликаты.",
@@ -1740,7 +1740,7 @@
"show_multiple_times": "Показать ограниченное количество раз",
"show_only_once": "Показать только один раз",
"show_question_settings": "Показать настройки вопроса",
"show_survey_maximum_of_n_times": "Показать опрос максимум <displayLimitInput /> раз.",
"show_survey_maximum_of_n_times": "Показывать опрос максимум <displayLimitInput /> раз.",
"show_survey_to_users": "Показать опрос % пользователей",
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
"shrink_preview": "Свернуть предпросмотр",
@@ -1853,8 +1853,8 @@
"visibility_and_recontact_description": "Управляйте, когда этот опрос может появляться и как часто он может повторяться.",
"visible": "Видимый",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Подождите несколько секунд после срабатывания триггера перед показом опроса",
"wait_n_days_before_showing_this_survey_again": "Подождать <daysInput /> или более дней между последним показом опроса и показом этого опроса.",
"wait_n_seconds_before_showing_the_survey": "Подождать <delayInput /> секунд перед показом опроса.",
"wait_n_days_before_showing_this_survey_again": "Подождите <daysInput /> или более дней между последним показом опроса и показом этого опроса.",
"wait_n_seconds_before_showing_the_survey": "Подождите <delayInput /> секунд перед показом опроса.",
"waiting_time_across_surveys": "Период ожидания (между опросами)",
"waiting_time_across_surveys_description": "Чтобы избежать усталости от опросов, выберите, как этот опрос взаимодействует с общим периодом ожидания в рабочем пространстве.",
"welcome_message": "Приветственное сообщение",
@@ -1891,7 +1891,6 @@
"completed": "Завершено ✅",
"country": "Страна",
"decrement_quotas": "Уменьшить все лимиты квот, включая этот ответ",
"delete_response": "Удалить ответ",
"delete_response_confirmation": "Это действие удалит ответ на опрос, включая все ответы, теги, вложенные документы и метаданные ответа.",
"delete_response_quotas": "Ответ входит в квоты для этого опроса. Как вы хотите обработать квоты?",
"device": "Устройство",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Поиск по названию опроса",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Создайте читаемый одноразовый ID и скопируйте подписанную ссылку для него.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Используй собственный одноразовый ID в URL.",
"custom_single_use_id_description": "Если вы не шифруете одноразовый идентификатор, любое значение для “suid=...” подойдет для одного ответа.",
"custom_single_use_id_title": "Вы можете задать любое значение в качестве одноразового ID в URL.",
"custom_start_point": "Пользовательская точка старта",
"data_prefilling": "Предзаполнение данных",
"description": "Ответы, полученные по этим ссылкам, будут анонимными",
+11 -14
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
"privacy_policy": "Integritetspolicy",
"product_updates_description": "Jag vill få månatliga produktuppdateringar via e-post från Formbricks. Integritetspolicyn gäller.",
"product_updates_title": "Månatliga produktuppdateringar via e-post",
"product_updates_description": "Månatliga produktnyheter och funktionsuppdateringar. Integritetspolicyn gäller.",
"product_updates_title": "Produktuppdateringar",
"security_updates_description": "Endast säkerhetsrelaterad information. Integritetspolicyn gäller.",
"security_updates_title": "Säkerhetsuppdateringar",
"terms_of_service": "Användarvillkor",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "1 dag kvar av din provperiod",
"try_again": "Försök igen",
"type": "Typ",
"unknown_survey": "Okänd enkät",
"unlock_more_workspaces_with_a_higher_plan": "Lås upp fler arbetsytor med ett högre abonnemang.",
"update": "Uppdatera",
"updated": "Uppdaterad",
@@ -715,7 +716,6 @@
"select_a_survey": "Välj en enkät",
"select_attribute": "Välj attribut",
"select_attribute_key": "Välj attributnyckel",
"survey_response_created": "Svar skapat",
"survey_viewed": "Enkät visad",
"survey_viewed_at": "Visad kl.",
"system_attributes": "Systemattribut",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
"all_are_true": "alla är sanna",
"all_other_answers_will_continue_to_fallback": "Alla andra svar kommer fortsätta att <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Alla andra svar kommer att fortsätta att <fallbackSelect />",
"allow_multi_select": "Tillåt flerval",
"allow_multiple_files": "Tillåt flera filer",
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
"automatically_close_survey_after_n_seconds_if_no_response": "Stäng enkäten automatiskt efter <autoCloseInput /> sekunder om inget svar ges.",
"automatically_close_survey_after_n_seconds_if_no_response": "Stäng undersökningen automatiskt efter <autoCloseInput /> sekunder efter utlösning om inget svar ges.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
"automatically_mark_complete_after_n_responses": "Markera enkäten automatiskt som slutförd efter <autoCompleteInput /> ifyllda svar.",
"automatically_mark_complete_after_n_responses": "Markera undersökningen automatiskt som slutförd efter <autoCompleteInput /> fullständiga svar.",
"back_button_label": "\"Tillbaka\"-knappens etikett",
"background_styling": "Bakgrundsstil",
"block_duplicated": "Block duplicerat.",
@@ -1740,7 +1740,7 @@
"show_multiple_times": "Visa ett begränsat antal gånger",
"show_only_once": "Visa endast en gång",
"show_question_settings": "Visa frågeinställningar",
"show_survey_maximum_of_n_times": "Visa enkäten maximalt <displayLimitInput /> gånger.",
"show_survey_maximum_of_n_times": "Visa undersökningen maximalt <displayLimitInput /> gånger.",
"show_survey_to_users": "Visa enkät för % av användare",
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
"shrink_preview": "Minimera förhandsgranskning",
@@ -1853,8 +1853,8 @@
"visibility_and_recontact_description": "Kontrollera när denna enkät kan visas och hur ofta den kan visas igen.",
"visible": "Synlig",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Vänta några sekunder efter utlösningen innan enkäten visas",
"wait_n_days_before_showing_this_survey_again": "Vänta <daysInput /> eller fler dagar mellan den senast visade enkäten och visning av denna enkät.",
"wait_n_seconds_before_showing_the_survey": "Vänta <delayInput /> sekunder innan enkäten visas.",
"wait_n_days_before_showing_this_survey_again": "Vänta <daysInput /> eller fler dagar mellan den senast visade undersökningen och att visa denna undersökning.",
"wait_n_seconds_before_showing_the_survey": "Vänta <delayInput /> sekunder innan undersökningen visas.",
"waiting_time_across_surveys": "Väntetid (mellan enkäter)",
"waiting_time_across_surveys_description": "För att undvika enkättrötthet, välj hur denna enkät ska förhålla sig till arbetsytans gemensamma väntetid.",
"welcome_message": "Välkomstmeddelande",
@@ -1891,7 +1891,6 @@
"completed": "Slutförd ✅",
"country": "Land",
"decrement_quotas": "Minska alla gränser för kvoter inklusive detta svar",
"delete_response": "Ta bort svar",
"delete_response_confirmation": "Detta kommer att ta bort enkätsvaret, inklusive alla svar, taggar, bifogade dokument och svarsmetadata.",
"delete_response_quotas": "Svaret är del av kvoter för denna enkät. Hur vill du hantera kvoterna?",
"device": "Enhet",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Sök efter enkätnamn",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Skapa ett läsbart engångs-ID och kopiera en signerad länk för det.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Använd ett anpassat engångs-ID i URL:en.",
"custom_single_use_id_description": "Om du inte krypterar engångs-ID fungerar vilket värde som helst för “suid=...” för ett svar.",
"custom_single_use_id_title": "Du kan ange vilket värde som helst som engångs-ID i URL:en.",
"custom_start_point": "Anpassad startpunkt",
"data_prefilling": "Dataförfyllning",
"description": "Svar från dessa länkar kommer att vara anonyma",
+10 -13
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Büyük ve küçük harf karışımı",
"please_verify_captcha": "Lütfen reCAPTCHA doğrulamasını yapın",
"privacy_policy": "Gizlilik Politikası",
"product_updates_description": "Formbricks'ten aylık ürün güncelleme e-postaları almak istiyorum. Gizlilik Politikası geçerlidir.",
"product_updates_title": "Aylık ürün güncelleme e-postaları",
"product_updates_description": "Aylık ürün haberleri ve özellik güncellemeleri, Gizlilik Politikası geçerlidir.",
"product_updates_title": "Ürün güncellemeleri",
"security_updates_description": "Yalnızca güvenlikle ilgili bilgiler, Gizlilik Politikası geçerlidir.",
"security_updates_title": "Güvenlik güncellemeleri",
"terms_of_service": "Hizmet Şartları",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "Deneme sürenizde 1 gün kaldı",
"try_again": "Tekrar dene",
"type": "Tür",
"unknown_survey": "Bilinmeyen anket",
"unlock_more_workspaces_with_a_higher_plan": "Daha yüksek bir planla daha fazla çalışma alanının kilidini açın.",
"update": "Güncelle",
"updated": "Güncellendi",
@@ -715,7 +716,6 @@
"select_a_survey": "Bir survey seçin",
"select_attribute": "Özellik Seçin",
"select_attribute_key": "Özellik anahtarı seçin",
"survey_response_created": "Yanıt oluşturuldu",
"survey_viewed": "Survey görüntülendi",
"survey_viewed_at": "Görüntülenme Tarihi",
"system_attributes": "Sistem Özellikleri",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "Survey kapatıldığında ziyaretçilerin gördüğü mesajı değiştirin.",
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
"all_are_true": "tümü doğru",
"all_other_answers_will_continue_to_fallback": "Diğer tüm yanıtlar <fallbackSelect /> işlemine devam edecek",
"all_other_answers_will_continue_to_fallback": "Diğer tüm yanıtlar <fallbackSelect /> olmaya devam edecek",
"allow_multi_select": "Çoklu seçime izin ver",
"allow_multiple_files": "Birden fazla dosyaya izin ver",
"allow_users_to_select_more_than_one_image": "Kullanıcıların birden fazla görsel seçmesine izin ver",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "Otomatik kayıt devre dışı",
"auto_save_disabled_tooltip": "Survey'iniz yalnızca taslak durumundayken otomatik kaydedilir. Bu, yayınlanmış survey'lerin yanlışlıkla güncellenmesini önler.",
"auto_save_on": "Otomatik kayıt açık",
"automatically_close_survey_after_n_seconds_if_no_response": "Tetiklemeden sonra yanıt alınmazsa <autoCloseInput /> saniye sonra anketi otomatik olarak kapat.",
"automatically_close_survey_after_n_seconds_if_no_response": "Yanıt verilmezse anketi tetiklendikten sonra <autoCloseInput /> saniye sonra otomatik olarak kapat.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Belirli sayıda yanıt sonrasında survey'i otomatik olarak kapatın.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Kullanıcı belirli bir süre yanıt vermezse survey'i otomatik olarak kapatın.",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput /> tamamlanmış yanıttan sonra anketi otomatik olarak tamamlandı olarak işaretle.",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput /> tamamlanmış yanıttan sonra anketi otomatik olarak tamamlandı işaretle.",
"back_button_label": "\"Geri\" Düğme Etiketi",
"background_styling": "Arka plan stili",
"block_duplicated": "Blok kopyalandı.",
@@ -1740,7 +1740,7 @@
"show_multiple_times": "Sınırlı sayıda göster",
"show_only_once": "Yalnızca bir kez göster",
"show_question_settings": "Soru ayarlarını göster",
"show_survey_maximum_of_n_times": "Anketi en fazla <displayLimitInput /> kez göster.",
"show_survey_maximum_of_n_times": "Anketi maksimum <displayLimitInput /> kez göster.",
"show_survey_to_users": "Kullanıcıların %'sine survey göster",
"show_to_x_percentage_of_targeted_users": "Hedeflenen kullanıcıların %{percentage}'ine göster",
"shrink_preview": "Önizlemeyi Küçült",
@@ -1853,7 +1853,7 @@
"visibility_and_recontact_description": "Bu survey'in ne zaman görünebileceğini ve ne sıklıkta tekrar gösterilebileceğini kontrol edin.",
"visible": "Görünür",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Survey'i göstermeden önce tetikleyiciden sonra birkaç saniye bekleyin",
"wait_n_days_before_showing_this_survey_again": "Son gösterilen anket ile bu anketi gösterme arasında <daysInput /> veya daha fazla gün bekle.",
"wait_n_days_before_showing_this_survey_again": "Son gösterilen anket ile bu anketin gösterilmesi arasında <daysInput /> veya daha fazla gün bekle.",
"wait_n_seconds_before_showing_the_survey": "Anketi göstermeden önce <delayInput /> saniye bekle.",
"waiting_time_across_surveys": "Bekleme Süresi (survey'ler arası)",
"waiting_time_across_surveys_description": "Survey yorgunluğunu önlemek için bu survey'in çalışma alanı genelindeki Bekleme Süresiyle nasıl etkileşeceğini seçin.",
@@ -1891,7 +1891,6 @@
"completed": "Tamamlandı ✅",
"country": "Ülke",
"decrement_quotas": "Bu yanıtı içeren tüm kota limitlerini azalt",
"delete_response": "Yanıtı sil",
"delete_response_confirmation": "Bu işlem, tüm yanıtlar, etiketler, ekli belgeler ve yanıt meta verileri dahil olmak üzere survey yanıtını silecek.",
"delete_response_quotas": "Yanıt bu survey'in kotalarına dahil. Kotaları nasıl yönetmek istiyorsunuz?",
"device": "Cihaz",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "Survey adına göre ara",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Okunabilir bir tek kullanımlık kimlik oluşturun ve bunun için imzalı bir bağlantı kopyalayın.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "URL'de özel bir tek kullanımlık kimlik kullanın.",
"custom_single_use_id_description": "Tek kullanımlık ID'leri şifrelemezseniz, \"suid=...\" için herhangi bir değer bir yanıt için geçerli olur.",
"custom_single_use_id_title": "URL'de herhangi bir değeri tek kullanımlık ID olarak ayarlayabilirsiniz.",
"custom_start_point": "Özel başlangıç noktası",
"data_prefilling": "Veri ön doldurma",
"description": "Bu bağlantılardan gelen yanıtlar anonim olacaktır",
+9 -12
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "大小写混合",
"please_verify_captcha": "请 验证 reCAPTCHA",
"privacy_policy": "隐私政策",
"product_updates_description": "我希望收到 Formbricks 每月产品更新邮件。隐私政策适用。",
"product_updates_title": "每月产品更新邮件",
"product_updates_description": "每月产品新闻和功能更新,适用隐私政策。",
"product_updates_title": "产品更新",
"security_updates_description": "仅限安全相关信息,适用隐私政策。",
"security_updates_title": "安全更新",
"terms_of_service": "服务条款",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "试用期还剩 1 天",
"try_again": "再试一次",
"type": "类型",
"unknown_survey": "未知调查",
"unlock_more_workspaces_with_a_higher_plan": "升级套餐以解锁更多工作区。",
"update": "更新",
"updated": "已更新",
@@ -715,7 +716,6 @@
"select_a_survey": "选择一个调查",
"select_attribute": "选择 属性",
"select_attribute_key": "选择属性键",
"survey_response_created": "回复已创建",
"survey_viewed": "已查看调查",
"survey_viewed_at": "查看时间",
"system_attributes": "系统属性",
@@ -1355,7 +1355,7 @@
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
"all_are_true": "全部为真",
"all_other_answers_will_continue_to_fallback": "所有其他答将继续<fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "所有其他答将继续<fallbackSelect />",
"allow_multi_select": "允许 多选",
"allow_multiple_files": "允许 多 个 文件",
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
"automatically_close_survey_after_n_seconds_if_no_response": "如果没有回复,在触发后 <autoCloseInput /> 秒自动关闭调查。",
"automatically_close_survey_after_n_seconds_if_no_response": "如果触发后无响应,则在 <autoCloseInput /> 秒自动关闭调查。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
"automatically_mark_complete_after_n_responses": "在获得 <autoCompleteInput /> 个完成的回复后自动将调查标记为完成。",
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 次完整响应后自动将调查标记为完成。",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景样式",
"block_duplicated": "区块已复制。",
@@ -1853,7 +1853,7 @@
"visibility_and_recontact_description": "控制此调查何时可以显示以及可以重新显示的频率。",
"visible": "可见",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "触发后等待几秒再显示问卷",
"wait_n_days_before_showing_this_survey_again": "在上次显示调查与显示此调查之间等待 <daysInput /> 天或更长时间。",
"wait_n_days_before_showing_this_survey_again": "在上次显示调查等待 <daysInput /> 天或更长时间再显示此调查。",
"wait_n_seconds_before_showing_the_survey": "在显示调查前等待 <delayInput /> 秒。",
"waiting_time_across_surveys": "冷却期(跨问卷)",
"waiting_time_across_surveys_description": "为防止问卷疲劳,请选择此问卷与工作区冷却期的交互方式。",
@@ -1891,7 +1891,6 @@
"completed": "完成 ✅",
"country": "国家",
"decrement_quotas": "减少所有配额限制,包括此回应",
"delete_response": "删除回复",
"delete_response_confirmation": "这 将 删除 调查 回应, 包括 所有 答案、 标签、 附件文档 和 回应元数据。",
"delete_response_quotas": "该响应是 此 调查配额 的一部分。 您 希望 如何 处理 这些 配额?",
"device": "设备",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "按 调查 名称 搜索",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "创建一个可读的一次性 ID,并复制其签名链接。",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "在 URL 中使用自定义一次性 ID。",
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用于一次答复。",
"custom_single_use_id_title": "您 可以 在 URL 中 设置 任意 值 作为 一次性 ID",
"custom_start_point": "自定义 起点",
"data_prefilling": "数据 预填充",
"description": "来自 这些 link 的 响应 将是 匿名 的",
+9 -12
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
"please_verify_captcha": "請驗證 reCAPTCHA",
"privacy_policy": "隱私權政策",
"product_updates_description": "我想收到 Formbricks 的每月產品更新電子郵件。隱私權政策適用。",
"product_updates_title": "每月產品更新電子郵件",
"product_updates_description": "每月產品新聞與功能更新,適用隱私權政策。",
"product_updates_title": "產品更新",
"security_updates_description": "僅限安全相關資訊,適用隱私權政策。",
"security_updates_title": "安全更新",
"terms_of_service": "服務條款",
@@ -462,6 +462,7 @@
"trial_one_day_remaining": "試用期剩餘 1 天",
"try_again": "再試一次",
"type": "類型",
"unknown_survey": "未知問卷",
"unlock_more_workspaces_with_a_higher_plan": "升級方案以解鎖更多工作區。",
"update": "更新",
"updated": "已更新",
@@ -715,7 +716,6 @@
"select_a_survey": "選擇問卷",
"select_attribute": "選取屬性",
"select_attribute_key": "選取屬性鍵值",
"survey_response_created": "回應已建立",
"survey_viewed": "已查看問卷",
"survey_viewed_at": "查看時間",
"system_attributes": "系統屬性",
@@ -1370,10 +1370,10 @@
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
"automatically_close_survey_after_n_seconds_if_no_response": "若觸發後無回應將在 <autoCloseInput /> 秒自動關閉問卷。",
"automatically_close_survey_after_n_seconds_if_no_response": "如果沒有回應,將在觸發後 <autoCloseInput /> 秒自動關閉問卷。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 完整回應後自動將問卷標記為已完成。",
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 完整回應後自動標記問卷為已完成。",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式",
"block_duplicated": "區塊已複製。",
@@ -1853,8 +1853,8 @@
"visibility_and_recontact_description": "控制此問卷何時可以顯示以及可以重新顯示的頻率。",
"visible": "可見",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷",
"wait_n_days_before_showing_this_survey_again": "在上次顯示問卷後等待 <daysInput /> 天或更長時間後再次顯示此問卷。",
"wait_n_seconds_before_showing_the_survey": "等待 <delayInput /> 秒後顯示問卷。",
"wait_n_days_before_showing_this_survey_again": "在上次顯示問卷後等待 <daysInput /> 天或更久,才再次顯示此問卷。",
"wait_n_seconds_before_showing_the_survey": "等待 <delayInput /> 秒後顯示問卷。",
"waiting_time_across_surveys": "冷卻期(跨問卷)",
"waiting_time_across_surveys_description": "為避免問卷疲勞,請選擇此問卷如何與工作區的冷卻期互動。",
"welcome_message": "歡迎訊息",
@@ -1891,7 +1891,6 @@
"completed": "已完成 ✅",
"country": "國家/地區",
"decrement_quotas": "減少所有配額限制,包括此回應",
"delete_response": "刪除回應",
"delete_response_confirmation": "這將刪除調查響應,包括所有回答、標籤、附件文件以及響應元數據。",
"delete_response_quotas": "回應 屬於 此 調查 的 配額 一部分 . 你 想 如何 處理 配額?",
"device": "裝置",
@@ -1919,10 +1918,8 @@
"search_by_survey_name": "依問卷名稱搜尋",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "建立可讀的單次使用 ID,並複製其簽署連結。",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "在網址中使用自訂的單次使用 ID。",
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用於一次回應。",
"custom_single_use_id_title": "您可以在 URL 中設置任何值 作為 一次性使用 ID",
"custom_start_point": "自訂 開始 點",
"data_prefilling": "資料預先填寫",
"description": "從 這些 連結 獲得 的 回應 將是 匿名 的",
@@ -43,12 +43,9 @@ export const ShareSurveyLink = ({
const previewUrl = new URL(surveyUrl);
if (survey.singleUse?.enabled) {
const singleUseLinkParams = await refreshSingleUseId();
if (singleUseLinkParams) {
previewUrl.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
previewUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
}
const newId = await refreshSingleUseId();
if (newId) {
previewUrl.searchParams.set("suId", newId);
}
}
@@ -9,7 +9,6 @@ import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Tag } from "@/modules/ui/components/tag";
import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
import { createTagAction, createTagToResponseAction, deleteTagOnResponseAction } from "../actions";
@@ -161,7 +160,6 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
/>
)}
</div>
<IdBadge id={responseId} />
</div>
);
};
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TResponse } from "@formbricks/types/responses";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface InfoIconButtonProps {
@@ -26,9 +25,11 @@ const InfoIconButton = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" aria-label={ariaLabel}>
<button
className="flex h-4 w-4 items-center justify-center rounded text-slate-500 hover:text-slate-700"
aria-label={ariaLabel}>
<Icon className="h-4 w-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
{tooltipContent}
@@ -1,6 +1,6 @@
"use client";
import { ReactNode, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
@@ -16,12 +16,7 @@ import { deleteResponseAction, getResponseAction } from "./actions";
import { ResponseTagsWrapper } from "./components/ResponseTagsWrapper";
import { SingleResponseCardBody } from "./components/SingleResponseCardBody";
import { SingleResponseCardHeader } from "./components/SingleResponseCardHeader";
import { isSubmissionTimeMoreThan5Minutes, isValidValue } from "./util";
export interface SingleResponseCardHeaderRenderProps {
onDeleteClick: () => void;
canResponseBeDeleted: boolean;
}
import { isValidValue } from "./util";
interface SingleResponseCardProps {
survey: TSurvey;
@@ -34,11 +29,6 @@ interface SingleResponseCardProps {
isReadOnly: boolean;
setSelectedResponseId?: (responseId: string | null) => void;
locale: TUserLocale;
/**
* Optional render-prop to replace the default header. Receives helpers to
* trigger the (shared) delete dialog so callers don't have to reimplement it.
*/
renderHeader?: (props: SingleResponseCardHeaderRenderProps) => ReactNode;
}
export const SingleResponseCard = ({
@@ -52,8 +42,7 @@ export const SingleResponseCard = ({
isReadOnly,
setSelectedResponseId,
locale,
renderHeader,
}: Readonly<SingleResponseCardProps>) => {
}: SingleResponseCardProps) => {
const hasQuotas = (response?.quotas && response.quotas.length > 0) ?? false;
const [decrementQuotas, setDecrementQuotas] = useState(hasQuotas);
const { t } = useTranslation();
@@ -139,30 +128,19 @@ export const SingleResponseCard = ({
}
};
const canResponseBeDeleted = response.finished
? true
: isSubmissionTimeMoreThan5Minutes(response.updatedAt);
return (
<div className="group relative">
<div className="relative z-20 rounded-xl border border-slate-200 bg-white shadow-sm transition-all">
{renderHeader ? (
renderHeader({
onDeleteClick: () => setDeleteDialogOpen(true),
canResponseBeDeleted,
})
) : (
<SingleResponseCardHeader
pageType="response"
response={response}
survey={survey}
environment={environment}
user={user}
isReadOnly={isReadOnly}
setDeleteDialogOpen={setDeleteDialogOpen}
locale={locale}
/>
)}
<div className="relative z-20 my-6 rounded-xl border border-slate-200 bg-white shadow-sm transition-all">
<SingleResponseCardHeader
pageType="response"
response={response}
survey={survey}
environment={environment}
user={user}
isReadOnly={isReadOnly}
setDeleteDialogOpen={setDeleteDialogOpen}
locale={locale}
/>
<SingleResponseCardBody
survey={survey}
+7 -5
View File
@@ -6,7 +6,6 @@ import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { hashPassword } from "@/lib/auth";
import {
EMAIL_VERIFICATION_DISABLED,
IS_FORMBRICKS_CLOUD,
IS_TURNSTILE_CONFIGURED,
TURNSTILE_SECRET_KEY,
@@ -46,6 +45,7 @@ const ZCreateUserAction = z.object({
password: ZUserPassword,
inviteToken: z.string().optional(),
userLocale: ZUserLocale.optional(),
emailVerificationDisabled: z.boolean().optional(),
turnstileToken: z
.string()
.optional()
@@ -53,6 +53,7 @@ const ZCreateUserAction = z.object({
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
"CAPTCHA verification required"
),
isFormbricksCloud: z.boolean(),
subscribeToSecurityUpdates: z.boolean().optional(),
subscribeToProductUpdates: z.boolean().optional(),
});
@@ -201,7 +202,8 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
async function handlePostUserCreation(
ctx: ActionClientCtx,
user: TCreatedUser,
inviteToken: string | undefined
inviteToken: string | undefined,
emailVerificationDisabled: boolean | undefined
): Promise<void> {
if (inviteToken) {
await handleInviteAcceptance(ctx, inviteToken, user);
@@ -209,7 +211,7 @@ async function handlePostUserCreation(
await handleOrganizationCreation(ctx, user);
}
if (!EMAIL_VERIFICATION_DISABLED) {
if (!emailVerificationDisabled) {
let inviteCallbackUrl: string | undefined;
if (inviteToken) {
@@ -241,11 +243,11 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
);
if (!userAlreadyExisted && user) {
await handlePostUserCreation(ctx, user, parsedInput.inviteToken);
await handlePostUserCreation(ctx, user, parsedInput.inviteToken, parsedInput.emailVerificationDisabled);
await subscribeUserToMailingList({
email: user.email,
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
isFormbricksCloud: parsedInput.isFormbricksCloud,
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
});
@@ -114,7 +114,9 @@ export const SignupForm = ({
password: data.password,
userLocale,
inviteToken: inviteToken ?? "",
emailVerificationDisabled,
turnstileToken,
isFormbricksCloud,
subscribeToSecurityUpdates,
subscribeToProductUpdates,
});
@@ -4,9 +4,8 @@ import { err, ok } from "@formbricks/types/error-handlers";
import { hashString } from "@/lib/hash-string";
// Import modules after mocking
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { applyClientRateLimit, applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
import { checkRateLimit } from "./rate-limit";
import { rateLimitConfigs } from "./rate-limit-configs";
// Mock all dependencies
vi.mock("@/lib/utils/client-ip", () => ({
@@ -197,62 +196,4 @@ describe("helpers", () => {
);
});
});
describe("applyClientRateLimit", () => {
test("should apply compound environment/IP and environment aggregate rate limits", async () => {
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyClientRateLimit("env_1")).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenNthCalledWith(1, rateLimitConfigs.api.client, "env_1:hashed-ip-123");
expect(checkRateLimit).toHaveBeenNthCalledWith(2, rateLimitConfigs.api.clientEnvironment, "env_1");
});
test("should apply custom config only to the compound environment/IP check", async () => {
const customConfig = {
interval: 60,
allowedPerInterval: 5,
namespace: "storage:upload",
};
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyClientRateLimit("env_1", customConfig)).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenNthCalledWith(1, customConfig, "env_1:hashed-ip-123");
expect(checkRateLimit).toHaveBeenNthCalledWith(2, rateLimitConfigs.api.clientEnvironment, "env_1");
});
test("should throw when the compound environment/IP rate limit is exceeded", async () => {
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: false }));
await expect(applyClientRateLimit("env_1")).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(checkRateLimit).toHaveBeenCalledTimes(1);
expect(checkRateLimit).toHaveBeenCalledWith(rateLimitConfigs.api.client, "env_1:hashed-ip-123");
});
test("should throw when the environment aggregate rate limit is exceeded", async () => {
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any)
.mockResolvedValueOnce(ok({ allowed: true }))
.mockResolvedValueOnce(ok({ allowed: false }));
await expect(applyClientRateLimit("env_1")).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(checkRateLimit).toHaveBeenNthCalledWith(1, rateLimitConfigs.api.client, "env_1:hashed-ip-123");
expect(checkRateLimit).toHaveBeenNthCalledWith(2, rateLimitConfigs.api.clientEnvironment, "env_1");
});
});
});
@@ -3,7 +3,6 @@ import { TooManyRequestsError } from "@formbricks/types/errors";
import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { checkRateLimit } from "./rate-limit";
import { rateLimitConfigs } from "./rate-limit-configs";
import { type TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
/**
@@ -59,24 +58,3 @@ export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<TRateL
const identifier = await getClientIdentifier();
return await applyRateLimit(config, identifier);
};
/**
* Apply public client API rate limiting scoped by environment.
*
* The compound environment/IP check keeps the existing per-client behavior without cross-environment
* interference, while the environment-only check bounds distributed-IP abuse against one environment.
*
* @param environmentId - Public client API environment ID from the route params
* @param customRateLimitConfig - Optional route-specific limit for the environment/IP check
* @throws {Error} When rate limit is exceeded or IP hashing fails
*/
export const applyClientRateLimit = async (
environmentId: string,
customRateLimitConfig?: TRateLimitConfig
): Promise<TRateLimitResponse> => {
const identifier = await getClientIdentifier();
const compoundIdentifier = `${environmentId}:${identifier}`;
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client, compoundIdentifier);
return await applyRateLimit(rateLimitConfigs.api.clientEnvironment, environmentId);
};
@@ -68,7 +68,7 @@ describe("rateLimitConfigs", () => {
test("should have all API configurations", () => {
const apiConfigs = Object.keys(rateLimitConfigs.api);
expect(apiConfigs).toEqual(["v1", "v2", "v3", "client", "clientEnvironment"]);
expect(apiConfigs).toEqual(["v1", "v2", "v3", "client"]);
});
test("should have all action configurations", () => {
@@ -139,7 +139,6 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.api.clientEnvironment, identifier: "environment-id" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
@@ -180,13 +179,5 @@ describe("rateLimitConfigs", () => {
expect(config.allowedPerInterval).toBe(5); // 5 requests per minute
expect(config.namespace).toBe("storage:delete");
});
test("should properly configure client environment rate limit", async () => {
const config = rateLimitConfigs.api.clientEnvironment;
expect(config.interval).toBe(60);
expect(config.allowedPerInterval).toBe(1000);
expect(config.namespace).toBe("api:client:environment");
});
});
});
@@ -13,11 +13,6 @@ export const rateLimitConfigs = {
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
v3: { interval: 60, allowedPerInterval: 100, namespace: "api:v3" }, // 100 per minute
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
clientEnvironment: {
interval: 60,
allowedPerInterval: 1000,
namespace: "api:client:environment",
}, // 1000 per minute per environment (Client API)
},
// Server actions - varies by action type
@@ -18,8 +18,8 @@ import { DisplayCard } from "./display-card";
import { ResponseSurveyCard } from "./response-survey-card";
type TTimelineItem =
| { type: "display"; data: Pick<TDisplay, "id" | "createdAt" | "surveyId">; survey: TSurvey }
| { type: "response"; data: TResponseWithQuotas; survey: TSurvey };
| { type: "display"; data: Pick<TDisplay, "id" | "createdAt" | "surveyId"> }
| { type: "response"; data: TResponseWithQuotas };
interface ActivityTimelineProps {
surveys: TSurvey[];
@@ -41,7 +41,7 @@ export const ActivityTimeline = ({
environmentTags,
locale,
projectPermission,
}: Readonly<ActivityTimelineProps>) => {
}: ActivityTimelineProps) => {
const { t } = useTranslation();
const [responses, setResponses] = useState(initialResponses);
const [isReversed, setIsReversed] = useState(false);
@@ -66,20 +66,16 @@ export const ActivityTimeline = ({
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
const surveyById = useMemo(() => {
return new Map(surveys.map((s) => [s.id, s]));
}, [surveys]);
const timelineItems = useMemo(() => {
const displayItems: TTimelineItem[] = displays.flatMap((d) => {
const survey = surveyById.get(d.surveyId);
return survey ? [{ type: "display" as const, data: d, survey }] : [];
});
const displayItems: TTimelineItem[] = displays.map((d) => ({
type: "display" as const,
data: d,
}));
const responseItems: TTimelineItem[] = responses.flatMap((r) => {
const survey = surveyById.get(r.surveyId);
return survey ? [{ type: "response" as const, data: r, survey }] : [];
});
const responseItems: TTimelineItem[] = responses.map((r) => ({
type: "response" as const,
data: r,
}));
const merged = [...displayItems, ...responseItems].sort((a, b) => {
const aTime = new Date(a.data.createdAt).getTime();
@@ -88,7 +84,7 @@ export const ActivityTimeline = ({
});
return isReversed ? [...merged].reverse() : merged;
}, [displays, responses, surveyById, isReversed]);
}, [displays, responses, isReversed]);
const toggleSort = () => {
setIsReversed((prev) => !prev);
@@ -116,7 +112,7 @@ export const ActivityTimeline = ({
<DisplayCard
key={`display-${item.data.id}`}
display={item.data}
survey={item.survey}
surveys={surveys}
environmentId={environment.id}
locale={locale}
/>
@@ -124,7 +120,7 @@ export const ActivityTimeline = ({
<ResponseSurveyCard
key={`response-${item.data.id}`}
response={item.data}
survey={item.survey}
surveys={surveys}
user={user}
environmentTags={environmentTags}
environment={environment}
@@ -1,8 +1,8 @@
"use client";
import { LinkIcon, PencilIcon, RefreshCwIcon, TrashIcon } from "lucide-react";
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
@@ -46,13 +46,6 @@ export const ContactControlBar = ({
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
const [isEditAttributesModalOpen, setIsEditAttributesModalOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
const handleRefresh = () => {
startRefreshTransition(() => {
router.refresh();
});
};
const handleDeletePerson = async () => {
setIsDeletingPerson(true);
@@ -69,22 +62,18 @@ export const ContactControlBar = ({
setDeleteDialogOpen(false);
};
if (isReadOnly) {
return null;
}
const iconActions = [
{
icon: RefreshCwIcon,
tooltip: t("common.refresh"),
onClick: handleRefresh,
isVisible: true,
disabled: isRefreshing,
iconClassName: isRefreshing ? "animate-spin" : undefined,
},
{
icon: PencilIcon,
tooltip: t("environments.contacts.edit_attributes"),
onClick: () => {
setIsEditAttributesModalOpen(true);
},
isVisible: !isReadOnly,
isVisible: true,
},
{
icon: LinkIcon,
@@ -92,7 +81,7 @@ export const ContactControlBar = ({
onClick: () => {
setIsGenerateLinkModalOpen(true);
},
isVisible: !isReadOnly,
isVisible: true,
},
{
icon: TrashIcon,
@@ -100,7 +89,7 @@ export const ContactControlBar = ({
onClick: () => {
setDeleteDialogOpen(true);
},
isVisible: !isReadOnly,
isVisible: true,
},
];
@@ -10,13 +10,14 @@ import { timeSince } from "@/lib/time";
interface DisplayCardProps {
display: Pick<TDisplay, "id" | "createdAt" | "surveyId">;
survey: TSurvey;
surveys: TSurvey[];
environmentId: string;
locale: TUserLocale;
}
export const DisplayCard = ({ display, survey, environmentId, locale }: Readonly<DisplayCardProps>) => {
export const DisplayCard = ({ display, surveys, environmentId, locale }: DisplayCardProps) => {
const { t } = useTranslation();
const survey = surveys.find((s) => s.id === display.surveyId);
return (
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
@@ -26,11 +27,15 @@ export const DisplayCard = ({ display, survey, environmentId, locale }: Readonly
</div>
<div>
<p className="text-xs text-slate-500">{t("environments.contacts.survey_viewed")}</p>
<Link
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}
className="text-sm font-medium text-slate-700 hover:underline">
{survey.name}
</Link>
{survey ? (
<Link
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}
className="text-sm font-medium text-slate-700 hover:underline">
{survey.name}
</Link>
) : (
<span className="text-sm font-medium text-slate-500">{t("common.unknown_survey")}</span>
)}
</div>
</div>
<span className="text-sm text-slate-500">{timeSince(display.createdAt.toString(), locale)}</span>
@@ -1,23 +1,16 @@
"use client";
import { MessageSquareTextIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ResponseSurveyCardProps {
response: TResponseWithQuotas;
survey: TSurvey;
surveys: TSurvey[];
user: TUser;
environmentTags: TTag[];
environment: TEnvironment;
@@ -29,7 +22,7 @@ interface ResponseSurveyCardProps {
export const ResponseSurveyCard = ({
response,
survey,
surveys,
user,
environmentTags,
environment,
@@ -37,74 +30,22 @@ export const ResponseSurveyCard = ({
updateResponse,
locale,
isReadOnly,
}: Readonly<ResponseSurveyCardProps>) => {
const { t } = useTranslation();
}: ResponseSurveyCardProps) => {
const survey = surveys.find((s) => s.id === response.surveyId);
const surveyWithReplacedRecall = useMemo(() => replaceHeadlineRecall(survey, "default"), [survey]);
const showDeleteButton = !!user && !isReadOnly;
if (!survey) return null;
return (
<SingleResponseCard
survey={surveyWithReplacedRecall}
response={response}
survey={replaceHeadlineRecall(survey, "default")}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
environment={environment}
updateResponseList={updateResponseList}
updateResponse={updateResponse}
isReadOnly={isReadOnly}
locale={locale}
renderHeader={({ onDeleteClick, canResponseBeDeleted }) => (
<div className="flex items-center justify-between p-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100">
<MessageSquareTextIcon className="h-4 w-4 text-slate-600" />
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">{t("environments.contacts.survey_response_created")}</p>
<Link
href={`/environments/${environment.id}/surveys/${survey.id}/summary`}
className="block truncate text-sm font-medium text-slate-700 hover:underline">
{survey.name}
</Link>
</div>
</div>
<div className="flex items-center gap-1 text-sm text-slate-500">
<time className="px-1" dateTime={response.createdAt.toString()}>
{timeSince(response.createdAt.toString(), locale)}
</time>
{showDeleteButton &&
(canResponseBeDeleted ? (
<Button
variant="ghost"
size="icon"
onClick={onDeleteClick}
aria-label={t("environments.surveys.responses.delete_response")}>
<TrashIcon className="h-4 w-4" />
</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled
className="text-slate-400"
aria-label={t("environments.surveys.responses.delete_response")}>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{t("environments.surveys.responses.this_response_is_in_progress")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
)}
/>
);
};
@@ -4,7 +4,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { ENCRYPTION_KEY } from "@/lib/constants";
import * as crypto from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { generateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
import { getSurvey } from "@/modules/survey/lib/survey";
import * as contactSurveyLink from "./contact-survey-link";
@@ -41,7 +41,7 @@ vi.mock("@/modules/survey/lib/survey", () => ({
}));
vi.mock("@/lib/utils/single-use-surveys", () => ({
generateSurveySingleUseLinkParams: vi.fn(),
generateSurveySingleUseId: vi.fn(),
}));
describe("Contact Survey Link", () => {
@@ -51,7 +51,7 @@ describe("Contact Survey Link", () => {
const mockEncryptedContactId = "encrypted-contact-id";
const mockEncryptedSurveyId = "encrypted-survey-id";
const mockedGetSurvey = vi.mocked(getSurvey);
const mockedGenerateSurveySingleUseLinkParams = vi.mocked(generateSurveySingleUseLinkParams);
const mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId);
beforeEach(() => {
vi.clearAllMocks();
@@ -78,10 +78,7 @@ describe("Contact Survey Link", () => {
id: mockSurveyId,
singleUse: { enabled: false, isEncrypted: false },
} as TSurvey);
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
suId: "single-use-id",
suToken: "signed-token",
});
mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id");
});
describe("getContactSurveyLink", () => {
@@ -108,7 +105,7 @@ describe("Contact Survey Link", () => {
data: `${getPublicDomain()}/c/${mockToken}`,
});
expect(mockedGenerateSurveySingleUseLinkParams).not.toHaveBeenCalled();
expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled();
});
test("adds expiration to the token when expirationDays is provided", async () => {
@@ -147,17 +144,14 @@ describe("Contact Survey Link", () => {
id: mockSurveyId,
singleUse: { enabled: true, isEncrypted: false },
} as TSurvey);
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
suId: "suId-unencrypted",
suToken: "signed-token",
});
mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted");
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, false);
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false);
expect(result).toEqual({
ok: true,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted&suToken=signed-token`,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`,
});
});
@@ -166,11 +160,11 @@ describe("Contact Survey Link", () => {
id: mockSurveyId,
singleUse: { enabled: true, isEncrypted: true },
} as TSurvey);
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({ suId: "suId-encrypted" });
mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted");
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, true);
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true);
expect(result).toEqual({
ok: true,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-encrypted`,
@@ -4,7 +4,7 @@ import { Result, err, ok } from "@formbricks/types/error-handlers";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { generateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getSurvey } from "@/modules/survey/lib/survey";
@@ -36,10 +36,10 @@ export const getContactSurveyLink = async (
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
let singleUseLinkParams: { suId: string; suToken?: string } | undefined;
let singleUseId: string | undefined;
if (isSingleUseEnabled) {
singleUseLinkParams = generateSurveySingleUseLinkParams(surveyId, isSingleUseEncrypted ?? false);
singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false);
}
// Create JWT payload with encrypted IDs
@@ -62,17 +62,9 @@ export const getContactSurveyLink = async (
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
// Return the personalized URL
const surveyUrl = `${getPublicDomain()}/c/${token}`;
if (!singleUseLinkParams) {
return ok(surveyUrl);
}
const searchParams = new URLSearchParams({ suId: singleUseLinkParams.suId });
if (singleUseLinkParams.suToken) {
searchParams.set("suToken", singleUseLinkParams.suToken);
}
return ok(`${surveyUrl}?${searchParams.toString()}`);
return singleUseId
? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`)
: ok(`${getPublicDomain()}/c/${token}`);
};
// Validates and decrypts a contact survey JWT token
@@ -12,7 +12,7 @@ vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -378,7 +378,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -410,7 +410,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -444,7 +444,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -475,7 +475,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -506,7 +506,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -571,7 +571,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -627,7 +627,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -683,7 +683,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -722,7 +722,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -748,7 +748,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -899,7 +899,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -946,7 +946,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: undefined,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -969,7 +969,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: testLicenseKey,
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
+1 -8
View File
@@ -357,20 +357,13 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
const email = data.email;
const surveyName = data.surveyName;
const singleUseId = data.suId;
const singleUseToken = data.suToken;
// Resolve relative storage URLs to absolute URLs for email rendering
const logoUrl = data.logoUrl ? resolveStorageUrl(data.logoUrl) : "";
const token = createTokenForLinkSurvey(surveyId, email);
const t = await getTranslate(data.locale);
const getSurveyLink = (): string => {
if (singleUseId) {
const surveyLink = new URL(`${getPublicDomain()}/s/${surveyId}`);
surveyLink.searchParams.set("verify", token);
surveyLink.searchParams.set("suId", singleUseId);
if (singleUseToken) {
surveyLink.searchParams.set("suToken", singleUseToken);
}
return surveyLink.toString();
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
}
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
};
@@ -43,7 +43,7 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
},
{
type: "projectTeam",
minPermission: "readWrite",
minPermission: "read",
projectId,
},
],
@@ -174,6 +174,13 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, {
choices: newChoices,
...(choiceId === "other" &&
!element.otherOptionPlaceholder && {
otherOptionPlaceholder: createI18nString(
t("environments.surveys.edit.please_specify"),
surveyLanguageCodes
),
}),
...(element.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id,
}),
@@ -2,17 +2,16 @@ import { useCallback, useState } from "react";
import toast from "react-hot-toast";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { TSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import type { TSurvey as TSurveyList } from "@/modules/survey/list/types/surveys";
export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolean) => {
const [singleUseLinkParams, setSingleUseLinkParams] = useState<TSurveySingleUseLinkParams>();
const [singleUseId, setSingleUseId] = useState<string>();
const refreshSingleUseId = useCallback(async (): Promise<TSurveySingleUseLinkParams | undefined> => {
const refreshSingleUseId = useCallback(async (): Promise<string | undefined> => {
if (isReadOnly || !survey.singleUse?.enabled) {
// If readonly or singleUse disabled, just clear and bail out
setSingleUseLinkParams(undefined);
setSingleUseId(undefined);
return undefined;
}
@@ -23,7 +22,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
});
if (response?.data?.length) {
setSingleUseLinkParams(response.data[0]);
setSingleUseId(response.data[0]);
return response.data[0];
} else {
toast.error(getFormattedErrorMessage(response));
@@ -32,8 +31,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
}, [survey, isReadOnly]);
return {
singleUseId: isReadOnly ? undefined : singleUseLinkParams?.suId,
singleUseToken: isReadOnly ? undefined : singleUseLinkParams?.suToken,
singleUseId: isReadOnly ? undefined : singleUseId,
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,
};
};
@@ -28,7 +28,6 @@ interface SurveyRendererProps {
embed?: string;
preview?: string;
suId?: string;
suToken?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
@@ -118,7 +117,6 @@ export const renderSurvey = async ({
return (
<VerifyEmail
singleUseId={searchParams.suId ?? ""}
singleUseToken={searchParams.suToken}
survey={survey}
languageCode={getLanguageCode(langParam, survey)}
styling={project.styling}
@@ -26,7 +26,6 @@ interface VerifyEmailProps {
survey: TSurvey;
isErrorComponent?: boolean;
singleUseId?: string;
singleUseToken?: string;
languageCode: string;
styling: TProjectStyling;
locale: TUserLocale;
@@ -41,7 +40,6 @@ export const VerifyEmail = ({
survey,
isErrorComponent,
singleUseId,
singleUseToken,
languageCode,
styling,
locale,
@@ -96,7 +94,6 @@ export const VerifyEmail = ({
email: email,
surveyName: localSurvey.name,
suId: singleUseId ?? "",
suToken: singleUseToken,
locale,
};
@@ -23,7 +23,6 @@ interface ContactSurveyPageProps {
}>;
searchParams: Promise<{
suId?: string;
suToken?: string;
verify?: string;
lang?: string;
embed?: string;
@@ -88,7 +87,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const t = await getTranslate();
const { jwt } = params;
const { preview, suId, suToken } = searchParams;
const { preview, suId } = searchParams;
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
@@ -128,12 +127,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(
suId,
isSingleUseSurveyEncrypted,
survey.id,
suToken
);
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
@@ -1,11 +1,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { checkAndValidateSingleUseId, getEmailVerificationDetails } from "./helper";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/jwt", () => ({
verifyTokenForLinkSurvey: vi.fn(),
}));
@@ -13,9 +10,6 @@ vi.mock("@/lib/jwt", () => ({
vi.mock("@/app/lib/singleUseSurveys", () => ({
validateSurveySingleUseId: vi.fn(),
}));
vi.mock("@/lib/utils/single-use-surveys", () => ({
validateSurveySingleUseLinkParams: vi.fn(),
}));
describe("getEmailVerificationDetails", () => {
const mockedVerifyTokenForLinkSurvey = vi.mocked(verifyTokenForLinkSurvey);
@@ -68,7 +62,6 @@ describe("getEmailVerificationDetails", () => {
describe("checkAndValidateSingleUseId", () => {
const mockedValidateSurveySingleUseId = vi.mocked(validateSurveySingleUseId);
const mockedValidateSurveySingleUseLinkParams = vi.mocked(validateSurveySingleUseLinkParams);
beforeEach(() => {
vi.resetAllMocks();
@@ -88,37 +81,19 @@ describe("checkAndValidateSingleUseId", () => {
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
test("returns null when isEncrypted is false and surveyId is missing", () => {
test("returns suid as-is when isEncrypted is false", () => {
const testSuid = "plain-suid-123";
const result = checkAndValidateSingleUseId(testSuid, false);
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
expect(mockedValidateSurveySingleUseLinkParams).not.toHaveBeenCalled();
});
test("returns signed suid when isEncrypted is false and signature validation succeeds", () => {
const testSuid = "plain-suid-123";
mockedValidateSurveySingleUseLinkParams.mockReturnValueOnce(testSuid);
const result = checkAndValidateSingleUseId(testSuid, false, "survey-1", "token-1");
expect(result).toBe(testSuid);
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
expect(mockedValidateSurveySingleUseLinkParams).toHaveBeenCalledWith({
surveyId: "survey-1",
suId: testSuid,
suToken: "token-1",
isEncrypted: false,
decrypt: expect.any(Function),
});
});
test("returns null when isEncrypted is false and signature validation fails", () => {
test("returns suid as-is when isEncrypted is not provided (defaults to false)", () => {
const testSuid = "plain-suid-123";
mockedValidateSurveySingleUseLinkParams.mockReturnValueOnce(null);
const result = checkAndValidateSingleUseId(testSuid, false, "survey-1");
const result = checkAndValidateSingleUseId(testSuid);
expect(result).toBeNull();
expect(result).toBe(testSuid);
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
+2 -20
View File
@@ -1,7 +1,6 @@
import "server-only";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
interface emailVerificationDetails {
status: "not-verified" | "verified" | "fishy";
@@ -28,12 +27,7 @@ export const getEmailVerificationDetails = async (
}
};
export const checkAndValidateSingleUseId = (
suid?: string,
isEncrypted = false,
surveyId?: string,
suToken?: string
): string | null => {
export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false): string | null => {
if (!suid?.trim()) return null;
if (isEncrypted) {
@@ -42,17 +36,5 @@ export const checkAndValidateSingleUseId = (
return validatedSingleUseId;
}
if (!surveyId) return null;
try {
return validateSurveySingleUseLinkParams({
surveyId,
suId: suid,
suToken,
isEncrypted,
decrypt: (encryptedSingleUseId) => validateSurveySingleUseId(encryptedSingleUseId) ?? "",
});
} catch {
return null;
}
return suid;
};
+1 -8
View File
@@ -18,7 +18,6 @@ interface LinkSurveyPageProps {
}>;
searchParams: Promise<{
suId?: string;
suToken?: string;
verify?: string;
lang?: string;
embed?: string;
@@ -85,7 +84,6 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
}
const suId = searchParams.suId;
const suToken = searchParams.suToken;
// Validate single-use ID early (no I/O, just validation)
const isSingleUseSurvey = survey.singleUse?.enabled;
@@ -93,12 +91,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(
suId,
isSingleUseSurveyEncrypted,
survey.id,
suToken
);
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
// Need to fetch project for error page - fetch environmentContext for it
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
+7 -23
View File
@@ -10,10 +10,7 @@ import {
getOrganizationIdFromSurveyId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import {
generateSurveySingleUseLinkParams,
generateSurveySingleUseLinkParamsList,
} from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { copySurveyToOtherEnvironment } from "@/modules/survey/list/lib/survey";
@@ -96,16 +93,11 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
})
);
const ZGenerateSingleUseIdAction = z
.object({
surveyId: z.cuid2(),
isEncrypted: z.boolean(),
count: z.number().min(1).max(5000).prefault(1),
singleUseId: z.string().trim().min(1).max(255).optional(),
})
.refine((data) => !data.singleUseId || (!data.isEncrypted && data.count === 1), {
message: "Custom single-use IDs can only be generated one at a time without encryption",
});
const ZGenerateSingleUseIdAction = z.object({
surveyId: z.cuid2(),
isEncrypted: z.boolean(),
count: z.number().min(1).max(5000).prefault(1),
});
export const generateSingleUseIdsAction = authenticatedActionClient
.inputSchema(ZGenerateSingleUseIdAction)
@@ -126,13 +118,5 @@ export const generateSingleUseIdsAction = authenticatedActionClient
],
});
if (parsedInput.singleUseId) {
return [generateSurveySingleUseLinkParams(parsedInput.surveyId, false, parsedInput.singleUseId)];
}
return generateSurveySingleUseLinkParamsList(
parsedInput.count,
parsedInput.surveyId,
parsedInput.isEncrypted
);
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
});
@@ -107,7 +107,7 @@ export const ManageTranslationsModal = ({
for (const s of strings) {
const val = draftTranslations[s.path] ?? "";
if (val) {
setTranslationAtPathMutable(clone, s.path, languageCode, val);
setTranslationAtPathMutable(clone, s.path, languageCode, val, s.value.default);
}
}
return clone;
@@ -121,7 +121,7 @@ export const ManageTranslationsModal = ({
const updatedSurvey = structuredClone(localSurvey);
for (const s of strings) {
const val = draftTranslations[s.path] ?? "";
setTranslationAtPathMutable(updatedSurvey, s.path, languageCode, val);
setTranslationAtPathMutable(updatedSurvey, s.path, languageCode, val, s.value.default);
}
setLocalSurvey(updatedSurvey);
setOpen(false);
@@ -0,0 +1,177 @@
import { type TFunction } from "i18next";
import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { computeTranslationProgress, extractTranslatableStrings, setTranslationAtPathMutable } from "./utils";
const t = ((key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
"common.choice_n": `Choice ${options?.n}`,
"common.headline": "Headline",
"common.other_placeholder": "Other Placeholder",
"environments.surveys.edit.please_specify": "Please specify",
};
return translations[key] ?? key;
}) as unknown as TFunction;
const createSurvey = (survey: Record<string, unknown>): TSurvey =>
({
welcomeCard: { enabled: false },
blocks: [],
endings: [],
metadata: {},
...survey,
}) as unknown as TSurvey;
describe("multi-language survey utils", () => {
test("extracts missing other option placeholders for single and multi select elements", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
},
{
id: "multi",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick many" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
},
],
},
],
});
const strings = extractTranslatableStrings(survey, t);
expect(strings).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "blocks.0.elements.0.otherOptionPlaceholder",
fieldLabel: "Other Placeholder",
value: { default: "Please specify" },
}),
expect.objectContaining({
path: "blocks.0.elements.1.otherOptionPlaceholder",
fieldLabel: "Other Placeholder",
value: { default: "Please specify" },
}),
])
);
});
test("keeps existing other option placeholder translations when default text is empty", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
otherOptionPlaceholder: { default: "", de: "Bitte angeben" },
},
],
},
],
});
const placeholder = extractTranslatableStrings(survey, t).find(
(string) => string.path === "blocks.0.elements.0.otherOptionPlaceholder"
);
expect(placeholder?.value).toEqual({ default: "Please specify", de: "Bitte angeben" });
expect(computeTranslationProgress([placeholder!], "de")).toEqual({
translated: 1,
total: 1,
percentage: 100,
});
});
test("does not extract stale other option placeholders without an other choice", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
],
otherOptionPlaceholder: { default: "Please specify" },
},
],
},
],
});
expect(
extractTranslatableStrings(survey, t).some(
(string) => string.path === "blocks.0.elements.0.otherOptionPlaceholder"
)
).toBe(false);
});
test("creates a missing translatable field when saving a translation with a default value", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
},
],
},
],
});
setTranslationAtPathMutable(
survey,
"blocks.0.elements.0.otherOptionPlaceholder",
"de",
"Bitte angeben",
"Please specify"
);
expect(survey.blocks[0].elements[0]).toMatchObject({
otherOptionPlaceholder: { default: "Please specify", de: "Bitte angeben" },
});
});
});
@@ -1,11 +1,13 @@
import { type TFunction } from "i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type { TSurveyMultipleChoiceElement, TSurveyRankingElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { isI18nObject } from "@/lib/i18n/utils";
import type { TranslatableString, TranslationProgress } from "./types";
const RICH_TEXT_FIELDS = new Set(["headline", "subheader", "html"]);
const OTHER_OPTION_PLACEHOLDER_FIELD = "otherOptionPlaceholder";
const pushIfI18n = (
result: TranslatableString[],
@@ -34,6 +36,34 @@ const pushIfI18n = (
}
};
const pushOtherOptionPlaceholder = (
result: TranslatableString[],
element: TSurveyMultipleChoiceElement | TSurveyRankingElement,
path: string,
displayId: string,
fieldLabel: string,
elementId: string,
defaultPlaceholder: string
) => {
const hasOtherChoice = element.choices?.some((choice) => choice.id === "other");
if (!hasOtherChoice) return;
const existingPlaceholder = element.otherOptionPlaceholder;
const defaultText = existingPlaceholder?.default?.trim() ? existingPlaceholder.default : defaultPlaceholder;
result.push({
path: `${path}.${OTHER_OPTION_PLACEHOLDER_FIELD}`,
displayId,
fieldLabel,
value: {
...(isI18nObject(existingPlaceholder) ? existingPlaceholder : {}),
default: defaultText,
},
isRichText: false,
elementId,
});
};
export const extractTranslatableStrings = (survey: TSurvey, t: TFunction): TranslatableString[] => {
const result: TranslatableString[] = [];
@@ -108,14 +138,14 @@ export const extractTranslatableStrings = (survey: TSurvey, t: TFunction): Trans
});
}
});
pushIfI18n(
pushOtherOptionPlaceholder(
result,
element,
"otherOptionPlaceholder",
base,
did,
t("common.other_placeholder"),
eid
eid,
t("environments.surveys.edit.please_specify")
);
break;
}
@@ -228,14 +258,14 @@ export const extractTranslatableStrings = (survey: TSurvey, t: TFunction): Trans
});
}
});
pushIfI18n(
pushOtherOptionPlaceholder(
result,
element,
"otherOptionPlaceholder",
base,
did,
t("common.other_placeholder"),
eid
eid,
t("environments.surveys.edit.please_specify")
);
break;
}
@@ -327,7 +357,8 @@ export const setTranslationAtPathMutable = (
survey: TSurvey,
path: string,
languageCode: string,
value: string
value: string,
defaultValue?: string
): void => {
const parts = path.split(".");
if (parts.length === 0) return;
@@ -347,5 +378,10 @@ export const setTranslationAtPathMutable = (
const target = current[lastPart];
if (isTraversable(target) && !Array.isArray(target) && "default" in target) {
(target as Record<string, string>)[languageCode] = value;
return;
}
if (target === undefined && defaultValue !== undefined && value.trim() !== "") {
current[lastPart] = { default: defaultValue, [languageCode]: value };
}
};
@@ -8,7 +8,6 @@ interface IconAction {
onClick?: () => void;
isVisible?: boolean;
disabled?: boolean;
iconClassName?: string;
}
interface IconBarProps {
@@ -17,30 +16,30 @@ interface IconBarProps {
}
export const IconBar = ({ actions }: IconBarProps) => {
const visibleActions = actions.filter((action) => action.isVisible);
if (visibleActions.length === 0) return null;
if (actions.length === 0) return null;
return (
<div
className="flex items-center justify-center divide-x rounded-md border border-slate-300 bg-white"
role="toolbar"
aria-label="Action buttons">
{visibleActions.map((action, index) => (
<span key={`${action.tooltip}-${index}`}>
<TooltipRenderer tooltipContent={action.tooltip}>
<Button
variant="ghost"
className="border-none hover:bg-slate-50"
size="icon"
onClick={action.onClick}
disabled={action.disabled}
aria-label={action.tooltip}>
<action.icon className={action.iconClassName} />
</Button>
</TooltipRenderer>
</span>
))}
{actions
.filter((action) => action.isVisible)
.map((action, index) => (
<span key={`${action.tooltip}-${index}`}>
<TooltipRenderer tooltipContent={action.tooltip}>
<Button
variant="ghost"
className="border-none hover:bg-slate-50"
size="icon"
onClick={action.onClick}
disabled={action.disabled}
aria-label={action.tooltip}>
<action.icon />
</Button>
</TooltipRenderer>
</span>
))}
</div>
);
};
+6 -6
View File
@@ -46,13 +46,13 @@
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.75.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.217.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.217.0",
"@opentelemetry/exporter-trace-otlp-http": "0.217.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-node": "0.217.0",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
"@opentelemetry/resources": "2.6.1",
"@opentelemetry/sdk-metrics": "2.6.1",
"@opentelemetry/sdk-node": "0.213.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@paralleldrive/cuid2": "2.3.1",
"@prisma/client": "6.19.3",
+1 -1
View File
@@ -1,6 +1,6 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is also required when running locally.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
+16
View File
@@ -0,0 +1,16 @@
{
"functions": {
"app/**/*.ts": {
"maxDuration": 10,
"memory": 512
},
"app/api/cron/**/*.ts": {
"maxDuration": 180,
"memory": 512
},
"app/api/v1/client/**/*.ts": {
"maxDuration": 10,
"memory": 200
}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ x-environment: &environment
################################################### OPTIONAL (STORAGE) ###################################################
# Set S3 Storage configuration (required for the file upload in serverless environments)
# Set S3 Storage configuration (required for the file upload in serverless environments like Vercel)
# S3_ACCESS_KEY:
# S3_SECRET_KEY:
# S3_REGION:
@@ -6,7 +6,7 @@ icon: code
## TypeScript
Our codebase uses the `@vercel/style-guide` ESLint configurations for consistent code quality.
Our codebase follows the Vercel Engineering Style Guide conventions.
### ESLint Configuration
+1 -1
View File
@@ -1323,7 +1323,7 @@ Please note that their values and the logic remains exactly the same. Only the p
### Deprecated Environment Variables
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as deployment URL fallback (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as Vercel URL (used instead of `WEBAPP_URL)`, but from v1.1, you can just set the `WEBAPP_URL` environment variable to your Vercel URL.
- **`RAILWAY_STATIC_URL`**: Was used as Railway Static URL (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
+5 -11
View File
@@ -84,26 +84,20 @@
"pnpm": {
"overrides": {
"@hono/node-server": "1.19.13",
"@protobufjs/utf8": "1.1.1",
"@tootallnate/once": "3.0.1",
"@xmldom/xmldom": "0.9.10",
"ajv@6": "6.14.0",
"axios": "1.15.2",
"effect": "3.20.0",
"fast-uri": "3.1.2",
"fast-xml-parser": "5.7.0",
"hono": "4.12.18",
"ip-address": "10.1.1",
"fast-xml-parser": "5.5.7",
"hono": "4.12.14",
"lodash": "4.18.1",
"node-forge": "1.4.0",
"postcss": "8.5.14",
"protobufjs@7": "7.5.8",
"protobufjs@8": "8.2.0",
"tar": "7.5.15",
"uuid@11": "11.1.1"
"@opentelemetry/otlp-transformer>protobufjs": "8.0.1",
"tar": "7.5.13"
},
"comments": {
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono via Prisma dev tooling | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @tootallnate/once and tar via sqlite3/node-gyp chain | @xmldom/xmldom (XML injection/DoS CVEs) - awaiting @boxyhq/saml20 to pin to >=0.9.10 | axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv@6 via webpack/eslint | effect (GHSA-38f7-945m-qr2g) - awaiting @prisma/config update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser via AWS SDK XML builder | ip-address (XSS in Address6) - awaiting mongodb/socks update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | uuid@11 (CVE-2025-61475) - awaiting typeorm update"
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono/effect via Prisma dev tooling | @tootallnate/once and tar via sqlite3/BoxyHQ SAML Jackson database tooling | @xmldom/xmldom, axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv via @vercel/style-guide/eslint-plugin-tsdoc | protobufjs via BoxyHQ/OpenTelemetry metrics | fast-xml-parser via AWS SDK XML builder."
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
-1
View File
@@ -6,7 +6,6 @@ export const ZLinkSurveyEmailData = z.object({
surveyId: z.string(),
email: z.string(),
suId: z.string().optional(),
suToken: z.string().optional(),
surveyName: z.string(),
locale: ZUserLocale,
logoUrl: ZStorageUrl.optional(),
+1471 -816
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -289,6 +289,8 @@
"RECAPTCHA_SECRET_KEY",
"TELEMETRY_DISABLED",
"TERMS_URL",
"VERCEL",
"VERCEL_URL",
"VERSION",
"WEBAPP_URL",
"UNSPLASH_ACCESS_KEY",