mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-16 19:48:48 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bef8dae328 | |||
| e7e751c1c5 | |||
| d1c9b8a5a3 |
+1
-1
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
apps/web/.env
|
||||
@@ -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)
|
||||
+12
-14
@@ -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
|
||||
|
||||
+2
-7
@@ -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>
|
||||
|
||||
+3
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-52
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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ókját.",
|
||||
"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ójá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 utolsó 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
@@ -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": "これらのリンクからの回答は匿名になります",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 的 响应 将是 匿名 的",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-3
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 read‑only 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Generated
+1471
-816
File diff suppressed because it is too large
Load Diff
@@ -289,6 +289,8 @@
|
||||
"RECAPTCHA_SECRET_KEY",
|
||||
"TELEMETRY_DISABLED",
|
||||
"TERMS_URL",
|
||||
"VERCEL",
|
||||
"VERCEL_URL",
|
||||
"VERSION",
|
||||
"WEBAPP_URL",
|
||||
"UNSPLASH_ACCESS_KEY",
|
||||
|
||||
Reference in New Issue
Block a user