mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 03:20:43 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b6140adbe | |||
| 884bac57d0 | |||
| d847723360 | |||
| ce7ea6b8c8 | |||
| ede12d825c |
+6
-3
@@ -111,9 +111,12 @@ export const SurveyAnalysisCTA = ({
|
||||
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
surveyUrl.searchParams.set("suId", newId);
|
||||
const singleUseLinkParams = await refreshSingleUseId();
|
||||
if (singleUseLinkParams) {
|
||||
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+52
-17
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -41,6 +41,7 @@ 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;
|
||||
@@ -48,12 +49,6 @@ 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 ?? {};
|
||||
@@ -181,10 +176,13 @@ export const AnonymousLinksTab = ({
|
||||
});
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => {
|
||||
const singleUseLinkParams = response.data;
|
||||
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", singleUseId);
|
||||
url.searchParams.set("suId", suId);
|
||||
if (suToken) {
|
||||
url.searchParams.set("suToken", suToken);
|
||||
}
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
@@ -212,6 +210,40 @@ 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">
|
||||
@@ -279,16 +311,19 @@ export const AnonymousLinksTab = ({
|
||||
</Alert>
|
||||
|
||||
<div className="grid w-full grid-cols-6 items-center gap-2">
|
||||
<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>
|
||||
<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"
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
disabled={!customSingleUseId.trim()}
|
||||
onClick={handleCopyCustomSingleUseLink}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
<CopyIcon />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
type PrismaKnownRequestError = Error & {
|
||||
code: string;
|
||||
meta?: {
|
||||
target?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is PrismaKnownRequestError => {
|
||||
if (!(error instanceof Error) || error.name !== "PrismaClientKnownRequestError") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof (error as { code?: unknown }).code === "string";
|
||||
};
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
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 (!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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
logger.error({ surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("An unexpected error occurred.", 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 };
|
||||
};
|
||||
@@ -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 } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -9,6 +9,8 @@ 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", () => ({
|
||||
@@ -137,6 +139,16 @@ 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,10 +2,14 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } 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/prisma-error";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
@@ -120,7 +124,11 @@ export const createResponse = async (
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,15 @@ import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, UniqueConstraintError } 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";
|
||||
@@ -129,112 +128,16 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
),
|
||||
};
|
||||
const singleUseValidationResult = validateSingleUseResponseInput(
|
||||
survey,
|
||||
environmentId,
|
||||
responseInputData
|
||||
);
|
||||
if (singleUseValidationResult) {
|
||||
if ("response" in singleUseValidationResult) {
|
||||
return { response: singleUseValidationResult.response };
|
||||
}
|
||||
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
@@ -278,6 +181,10 @@ 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 { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -56,13 +56,22 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||
const singleUseLinkParams = generateSurveySingleUseLinkParamsList(
|
||||
limit,
|
||||
survey.id,
|
||||
survey.singleUse.isEncrypted
|
||||
);
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
// map single use ids to survey links
|
||||
const surveyLinks = singleUseIds.map(
|
||||
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyLinks),
|
||||
|
||||
@@ -6,6 +6,10 @@ 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/prisma-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";
|
||||
@@ -128,12 +132,9 @@ export const createResponse = async (
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
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");
|
||||
}
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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", () => ({
|
||||
@@ -52,6 +53,11 @@ 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",
|
||||
@@ -90,6 +96,7 @@ const mockSurvey: TSurvey = {
|
||||
showLanguageSwitch: false,
|
||||
blocks: [],
|
||||
isCaptureIpEnabled: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
};
|
||||
@@ -111,6 +118,7 @@ const mockBillingData: TOrganizationBilling = {
|
||||
usageCycleAnchor: new Date(),
|
||||
stripeCustomerId: "mock-stripe-customer-id",
|
||||
};
|
||||
const validSingleUseId = "cm8f4x9mm0001gx9h5b7d7h3q";
|
||||
|
||||
describe("checkSurveyValidity", () => {
|
||||
beforeEach(() => {
|
||||
@@ -222,10 +230,14 @@ 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",
|
||||
});
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
|
||||
@@ -237,10 +249,14 @@ 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",
|
||||
});
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Missing or invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if meta.url is invalid", async () => {
|
||||
@@ -254,7 +270,8 @@ describe("checkSurveyValidity", () => {
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid URL in response metadata",
|
||||
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
|
||||
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
@@ -268,16 +285,20 @@ 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",
|
||||
});
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
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("decrypted-id");
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
|
||||
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
@@ -286,15 +307,20 @@ 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",
|
||||
});
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-2";
|
||||
const suToken = generateSurveySingleUseSignature(survey.id, "su-2");
|
||||
const url = `https://example.com/?suId=su-2&suToken=${suToken}`;
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
@@ -302,13 +328,17 @@ 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",
|
||||
});
|
||||
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 suId matches singleUseId", async () => {
|
||||
test("should return badRequestResponse if not encrypted and suToken is missing", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-1";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
@@ -316,16 +346,39 @@ 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("su-1");
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
|
||||
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
singleUseId: validSingleUseId,
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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";
|
||||
|
||||
@@ -20,53 +19,12 @@ export const checkSurveyValidity = async (
|
||||
return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const singleUseValidationResult = validateSingleUseResponseInput(survey, environmentId, responseInput);
|
||||
if (singleUseValidationResult) {
|
||||
if ("response" in singleUseValidationResult) {
|
||||
return singleUseValidationResult.response;
|
||||
}
|
||||
responseInput.singleUseId = singleUseValidationResult.singleUseId;
|
||||
}
|
||||
|
||||
if (survey.recaptcha?.enabled) {
|
||||
|
||||
@@ -217,8 +217,7 @@ const successResponse = (data: Object, cors: boolean = false, cache: string = "p
|
||||
const internalServerErrorResponse = (
|
||||
message: string,
|
||||
cors: boolean = false,
|
||||
cache: string = "private, no-store",
|
||||
details: ApiErrorResponse["details"] = {}
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
@@ -229,7 +228,7 @@ const internalServerErrorResponse = (
|
||||
{
|
||||
code: "internal_server_error",
|
||||
message,
|
||||
details,
|
||||
details: {},
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 500,
|
||||
|
||||
@@ -2,7 +2,13 @@ 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 } from "./single-use-surveys";
|
||||
import {
|
||||
generateSurveySingleUseId,
|
||||
generateSurveySingleUseIds,
|
||||
generateSurveySingleUseLinkParams,
|
||||
validateSurveySingleUseLinkParams,
|
||||
validateSurveySingleUseSignature,
|
||||
} from "./single-use-surveys";
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
symmetricEncrypt: vi.fn(),
|
||||
@@ -112,4 +118,87 @@ 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,7 +1,23 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { createId, isCuid } from "@paralleldrive/cuid2";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
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();
|
||||
@@ -26,3 +42,87 @@ 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;
|
||||
};
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
|
||||
"field_placeholder": "{{field}}-Platzhalter",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Fertigstellen",
|
||||
"first_name": "Vorname",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Nach Umfragenamen suchen",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Benutzerdefinierter Startpunkt",
|
||||
"data_prefilling": "Daten-Prefilling",
|
||||
"description": "Antworten, die von diesen Links kommen, werden anonym",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
|
||||
"delete_workspace": "Projekt löschen",
|
||||
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_workspace_confirmation_name": "Bitte gib {projectName} in das folgende Feld ein, um die endgültige Löschung dieses Projekts zu bestätigen:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
|
||||
"delete_workspace_settings_description": "Projekt mit allen Umfragen, Antworten, Personen, Aktionen und Attributen löschen. Das kann nicht rückgängig gemacht werden.",
|
||||
"error_saving_workspace_information": "Fehler beim Speichern der Projektinformationen",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||
"field_placeholder": "{{field}} Placeholder",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Search by survey name",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Custom start point",
|
||||
"data_prefilling": "Data prefilling",
|
||||
"description": "Responses coming from these links will be anonymous",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||
"delete_workspace": "Delete Workspace",
|
||||
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
|
||||
"delete_workspace_confirmation_name": "Please enter {projectName} in the following field to confirm the definitive deletion of this workspace:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} including all surveys, responses, people, actions and attributes.",
|
||||
"delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.",
|
||||
"error_saving_workspace_information": "Error saving workspace information",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
||||
"field_placeholder": "Marcador de posición de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Finalizar",
|
||||
"first_name": "Nombre",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Buscar por nombre de encuesta",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Punto de inicio personalizado",
|
||||
"data_prefilling": "Prellenado de datos",
|
||||
"description": "Las respuestas procedentes de estos enlaces serán anónimas",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
|
||||
"delete_workspace": "Eliminar proyecto",
|
||||
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
|
||||
"delete_workspace_confirmation_name": "Por favor, introduce {projectName} en el siguiente campo para confirmar la eliminación definitiva de este proyecto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
|
||||
"delete_workspace_settings_description": "Eliminar proyecto con todas las encuestas, respuestas, personas, acciones y atributos. Esto no se puede deshacer.",
|
||||
"error_saving_workspace_information": "Error al guardar la información del proyecto",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_workspaces": "Échec du chargement des projets",
|
||||
"field_placeholder": "Espace réservé {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtre",
|
||||
"finish": "Terminer",
|
||||
"first_name": "Prénom",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Recherche par nom d'enquête",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Point de départ personnalisé",
|
||||
"data_prefilling": "Préremplissage des données",
|
||||
"description": "Les réponses provenant de ces liens seront anonymes",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
|
||||
"delete_workspace": "Supprimer le projet",
|
||||
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.",
|
||||
"delete_workspace_confirmation_name": "Veuillez entrer {projectName} dans le champ suivant pour confirmer la suppression définitive de ce projet :",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
|
||||
"delete_workspace_settings_description": "Supprimer le projet avec toutes les enquêtes, réponses, personnes, actions et attributs. Cette opération est irréversible.",
|
||||
"error_saving_workspace_information": "Erreur lors de l'enregistrement des informations du projet",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||
"field_placeholder": "{{field}} helykitöltője",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Szűrő",
|
||||
"finish": "Befejezés",
|
||||
"first_name": "Keresztnév",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Keresés kérdőívnév alapján",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_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",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"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} 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",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
||||
"field_placeholder": "{{field}} プレースホルダー",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "フィルター",
|
||||
"finish": "完了",
|
||||
"first_name": "名",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "フォーム名で検索",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "シングルユースIDを暗号化しない場合、「suid=...」の任意の値で1回の回答が可能になります。",
|
||||
"custom_single_use_id_title": "URLで任意の値を単一使用IDとして設定できます。",
|
||||
"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_start_point": "カスタム開始点",
|
||||
"data_prefilling": "データの事前入力",
|
||||
"description": "これらのリンクからの回答は匿名になります",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
|
||||
"delete_workspace": "ワークスペースを削除",
|
||||
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
|
||||
"delete_workspace_confirmation_name": "このワークスペースの完全な削除を確認するには、以下のフィールドに {projectName} と入力してください:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
||||
"delete_workspace_settings_description": "すべてのフォーム、回答、人物、アクション、属性を含むワークスペースを削除します。この操作は元に戻せません。",
|
||||
"error_saving_workspace_information": "ワークスペース情報の保存中にエラーが発生しました",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
||||
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
|
||||
"field_placeholder": "Tijdelijke aanduiding voor {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "Voornaam",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Zoek op enquêtenaam",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Aangepast startpunt",
|
||||
"data_prefilling": "Gegevens vooraf invullen",
|
||||
"description": "Reacties afkomstig van deze links zijn anoniem",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
|
||||
"delete_workspace": "Project verwijderen",
|
||||
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_workspace_confirmation_name": "Voer {projectName} in het volgende veld in om de definitieve verwijdering van dit project te bevestigen:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
|
||||
"delete_workspace_settings_description": "Verwijder project met alle enquêtes, reacties, mensen, acties en attributen. Dit kan niet ongedaan worden gemaakt.",
|
||||
"error_saving_workspace_information": "Fout bij opslaan van projectinformatie",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"field_placeholder": "Espaço reservado de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Terminar",
|
||||
"first_name": "Primeiro nome",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Buscar pelo nome da pesquisa",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Ponto de início personalizado",
|
||||
"data_prefilling": "preenchimento automático de dados",
|
||||
"description": "Respostas vindas desses links serão anônimas",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
|
||||
"delete_workspace": "Excluir projeto",
|
||||
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
|
||||
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo abaixo para confirmar a exclusão definitiva deste projeto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
|
||||
"delete_workspace_settings_description": "Excluir projeto com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.",
|
||||
"error_saving_workspace_information": "Erro ao salvar informações do projeto",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"field_placeholder": "Espaço reservado de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Concluir",
|
||||
"first_name": "Primeiro nome",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Pesquisar por nome do inquérito",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Ponto de início personalizado",
|
||||
"data_prefilling": "Pré-preenchimento de dados",
|
||||
"description": "Respostas provenientes destes links serão anónimas",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
|
||||
"delete_workspace": "Eliminar projeto",
|
||||
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
|
||||
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo seguinte para confirmar a eliminação definitiva deste projeto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
|
||||
"delete_workspace_settings_description": "Eliminar projeto com todos os inquéritos, respostas, pessoas, ações e atributos. Isto não pode ser desfeito.",
|
||||
"error_saving_workspace_information": "Erro ao guardar informações do projeto",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
|
||||
"field_placeholder": "Substituent {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtru",
|
||||
"finish": "Finalizează",
|
||||
"first_name": "Prenume",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Căutare după nume chestionar",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Punct de start personalizat",
|
||||
"data_prefilling": "Precompletare date",
|
||||
"description": "Răspunsurile provenite de la aceste linkuri vor fi anonime",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
|
||||
"delete_workspace": "Șterge proiectul",
|
||||
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
|
||||
"delete_workspace_confirmation_name": "Vă rugăm să introduceți {projectName} în câmpul următor pentru a confirma ștergerea definitivă a acestui proiect:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
|
||||
"delete_workspace_settings_description": "Șterge proiectul cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Aceasta nu poate fi anulată.",
|
||||
"error_saving_workspace_information": "Eroare la salvarea informațiilor despre proiect",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Не удалось загрузить организации",
|
||||
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
||||
"field_placeholder": "Заполнитель {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Фильтр",
|
||||
"finish": "Завершить",
|
||||
"first_name": "Имя",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Поиск по названию опроса",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Если вы не шифруете одноразовый идентификатор, любое значение для “suid=...” подойдет для одного ответа.",
|
||||
"custom_single_use_id_title": "Вы можете задать любое значение в качестве одноразового ID в URL.",
|
||||
"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_start_point": "Пользовательская точка старта",
|
||||
"data_prefilling": "Предзаполнение данных",
|
||||
"description": "Ответы, полученные по этим ссылкам, будут анонимными",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
|
||||
"delete_workspace": "Удалить рабочий проект",
|
||||
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
|
||||
"delete_workspace_confirmation_name": "Пожалуйста, введите {projectName} в поле ниже для подтверждения окончательного удаления этого рабочего проекта:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
||||
"delete_workspace_settings_description": "Удалить рабочий проект со всеми опросами, ответами, пользователями, действиями и атрибутами. Это действие необратимо.",
|
||||
"error_saving_workspace_information": "Ошибка при сохранении информации о рабочем проекте",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
|
||||
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
|
||||
"field_placeholder": "Platshållare för {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Slutför",
|
||||
"first_name": "Förnamn",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Sök efter enkätnamn",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Anpassad startpunkt",
|
||||
"data_prefilling": "Dataförfyllning",
|
||||
"description": "Svar från dessa länkar kommer att vara anonyma",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
|
||||
"delete_workspace": "Ta bort arbetsyta",
|
||||
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
|
||||
"delete_workspace_confirmation_name": "Vänligen ange {projectName} i följande fält för att bekräfta den definitiva borttagningen av denna arbetsyta:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
|
||||
"delete_workspace_settings_description": "Ta bort arbetsyta med alla enkäter, svar, personer, åtgärder och attribut. Detta kan inte ångras.",
|
||||
"error_saving_workspace_information": "Fel vid sparande av arbetsytans information",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "Organizasyonlar yüklenemedi",
|
||||
"failed_to_load_workspaces": "Çalışma alanları yüklenemedi",
|
||||
"field_placeholder": "{{field}} Yer Tutucu",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtre",
|
||||
"finish": "Bitir",
|
||||
"first_name": "Ad",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "Survey adına göre ara",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"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_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_start_point": "Özel başlangıç noktası",
|
||||
"data_prefilling": "Veri ön doldurma",
|
||||
"description": "Bu bağlantılardan gelen yanıtlar anonim olacaktır",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "Betikler tam tarayıcı erişimiyle çalışır. Yalnızca güvenilir kaynaklardan betik ekleyin.",
|
||||
"delete_workspace": "Çalışma Alanını Sil",
|
||||
"delete_workspace_confirmation": "{projectName} öğesini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"delete_workspace_confirmation_name": "Bu çalışma alanının kesin olarak silinmesini onaylamak için lütfen aşağıdaki alana {projectName} yazın:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Tüm survey, yanıt, kişi, eylem ve nitelikleri dahil {projectName} öğesini silin.",
|
||||
"delete_workspace_settings_description": "Tüm survey, yanıt, kişi, eylem ve nitelikleriyle birlikte çalışma alanını silin. Bu işlem geri alınamaz.",
|
||||
"error_saving_workspace_information": "Çalışma alanı bilgileri kaydedilirken hata oluştu",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_workspaces": "加载工作区失败",
|
||||
"field_placeholder": "{{field}} 占位符",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "筛选",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "按 调查 名称 搜索",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用于一次答复。",
|
||||
"custom_single_use_id_title": "您 可以 在 URL 中 设置 任意 值 作为 一次性 ID。",
|
||||
"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_start_point": "自定义 起点",
|
||||
"data_prefilling": "数据 预填充",
|
||||
"description": "来自 这些 link 的 响应 将是 匿名 的",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
|
||||
"delete_workspace": "删除工作区",
|
||||
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
|
||||
"delete_workspace_confirmation_name": "请在下列字段中输入 {projectName} 以确认永久删除此工作区:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
|
||||
"delete_workspace_settings_description": "删除工作区及其所有调查、回应、人员、动作和属性。此操作无法撤销。",
|
||||
"error_saving_workspace_information": "保存工作区信息时出错",
|
||||
|
||||
@@ -241,9 +241,6 @@
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_workspaces": "載入工作區失敗",
|
||||
"field_placeholder": "{{field}} 預設文字",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "篩選",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
@@ -1928,8 +1925,10 @@
|
||||
"search_by_survey_name": "依問卷名稱搜尋",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用於一次回應。",
|
||||
"custom_single_use_id_title": "您可以在 URL 中設置任何值 作為 一次性使用 ID",
|
||||
"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_start_point": "自訂 開始 點",
|
||||
"data_prefilling": "資料預先填寫",
|
||||
"description": "從 這些 連結 獲得 的 回應 將是 匿名 的",
|
||||
@@ -2220,7 +2219,6 @@
|
||||
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
|
||||
"delete_workspace": "刪除工作區",
|
||||
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
|
||||
"delete_workspace_confirmation_name": "請在下列欄位中輸入 {projectName} 以確認永久刪除此工作區:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
|
||||
"delete_workspace_settings_description": "刪除工作區及其所有問卷、回應、人員、操作和屬性。此操作無法復原。",
|
||||
"error_saving_workspace_information": "儲存工作區資訊時發生錯誤",
|
||||
|
||||
@@ -43,9 +43,12 @@ export const ShareSurveyLink = ({
|
||||
const previewUrl = new URL(surveyUrl);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
previewUrl.searchParams.set("suId", newId);
|
||||
const singleUseLinkParams = await refreshSingleUseId();
|
||||
if (singleUseLinkParams) {
|
||||
previewUrl.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
previewUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseLinkParams } 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", () => ({
|
||||
generateSurveySingleUseId: vi.fn(),
|
||||
generateSurveySingleUseLinkParams: 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 mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId);
|
||||
const mockedGenerateSurveySingleUseLinkParams = vi.mocked(generateSurveySingleUseLinkParams);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -78,7 +78,10 @@ describe("Contact Survey Link", () => {
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id");
|
||||
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
|
||||
suId: "single-use-id",
|
||||
suToken: "signed-token",
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactSurveyLink", () => {
|
||||
@@ -105,7 +108,7 @@ describe("Contact Survey Link", () => {
|
||||
data: `${getPublicDomain()}/c/${mockToken}`,
|
||||
});
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
expect(mockedGenerateSurveySingleUseLinkParams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("adds expiration to the token when expirationDays is provided", async () => {
|
||||
@@ -144,14 +147,17 @@ describe("Contact Survey Link", () => {
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted");
|
||||
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
|
||||
suId: "suId-unencrypted",
|
||||
suToken: "signed-token",
|
||||
});
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false);
|
||||
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, false);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted&suToken=signed-token`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,11 +166,11 @@ describe("Contact Survey Link", () => {
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: true },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted");
|
||||
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({ suId: "suId-encrypted" });
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true);
|
||||
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, 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 { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseLinkParams } 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 singleUseId: string | undefined;
|
||||
let singleUseLinkParams: { suId: string; suToken?: string } | undefined;
|
||||
|
||||
if (isSingleUseEnabled) {
|
||||
singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false);
|
||||
singleUseLinkParams = generateSurveySingleUseLinkParams(surveyId, isSingleUseEncrypted ?? false);
|
||||
}
|
||||
|
||||
// Create JWT payload with encrypted IDs
|
||||
@@ -62,9 +62,17 @@ export const getContactSurveyLink = async (
|
||||
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
|
||||
|
||||
// Return the personalized URL
|
||||
return singleUseId
|
||||
? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`)
|
||||
: ok(`${getPublicDomain()}/c/${token}`);
|
||||
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()}`);
|
||||
};
|
||||
|
||||
// Validates and decrypts a contact survey JWT token
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@ import {
|
||||
updateOrganizationEmailLogoUrlAction,
|
||||
} from "@/modules/ee/whitelabel/email-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
|
||||
@@ -137,7 +136,7 @@ export const EmailCustomizationSettings = ({
|
||||
const { url, error } = await handleFileUpload(logoFile, environmentId, allowedFileExtensions);
|
||||
|
||||
if (error) {
|
||||
showFileUploadErrorToast(error, t);
|
||||
toast.error(error);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-2
@@ -14,7 +14,6 @@ import {
|
||||
updateOrganizationFaviconUrlAction,
|
||||
} from "@/modules/ee/whitelabel/favicon-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
@@ -59,7 +58,7 @@ export const FaviconCustomizationSettings = ({
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId, allowedFileExtensions);
|
||||
if (uploadResult.error) {
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
toast.error(uploadResult.error);
|
||||
return;
|
||||
}
|
||||
setFaviconUrl(uploadResult.url);
|
||||
|
||||
@@ -357,13 +357,20 @@ 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) {
|
||||
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${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)}`;
|
||||
};
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { isExpectedError } from "@formbricks/types/errors";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { getProject, getUserProjects } from "@/lib/project/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { deleteProjectWithConfirmation, getProjectIdForLogging } from "./lib/delete-project";
|
||||
import { deleteProject } from "@/modules/projects/settings/lib/project";
|
||||
|
||||
const logProjectDeletionError = (userId: string, projectId: string, error: unknown) => {
|
||||
logger.error({ error, userId, projectId }, "Workspace deletion failed");
|
||||
};
|
||||
const ZProjectDeleteAction = z.object({
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
const shouldLogProjectDeletionError = (error: unknown) => {
|
||||
return !(error instanceof Error && isExpectedError(error));
|
||||
};
|
||||
|
||||
export const deleteProjectAction = authenticatedActionClient.inputSchema(z.unknown()).action(
|
||||
export const deleteProjectAction = authenticatedActionClient.inputSchema(ZProjectDeleteAction).action(
|
||||
withAuditLogging("deleted", "project", async ({ ctx, parsedInput }) => {
|
||||
const projectIdForLogging = getProjectIdForLogging(parsedInput);
|
||||
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
|
||||
|
||||
try {
|
||||
return await deleteProjectWithConfirmation({
|
||||
input: parsedInput,
|
||||
userId: ctx.user.id,
|
||||
auditLoggingCtx: ctx.auditLoggingCtx,
|
||||
});
|
||||
} catch (error) {
|
||||
if (shouldLogProjectDeletionError(error)) {
|
||||
logProjectDeletionError(ctx.user.id, projectIdForLogging, error);
|
||||
}
|
||||
throw error;
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const availableProjects = (await getUserProjects(ctx.user.id, organizationId)) ?? null;
|
||||
|
||||
if (!!availableProjects && availableProjects?.length <= 1) {
|
||||
throw new Error("You can't delete the last project in the environment.");
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
|
||||
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
|
||||
|
||||
// delete project
|
||||
return await deleteProject(parsedInput.projectId);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,17 +4,14 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { truncate } from "@/lib/utils/strings";
|
||||
import { deleteProjectAction } from "@/modules/projects/settings/general/actions";
|
||||
import { hasMatchingWorkspaceDeleteConfirmation } from "@/modules/projects/settings/general/lib/delete-project-confirmation";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface DeleteProjectRenderProps {
|
||||
isDeleteDisabled: boolean;
|
||||
@@ -33,55 +30,30 @@ export const DeleteProjectRender = ({
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [confirmationName, setConfirmationName] = useState("");
|
||||
const hasValidConfirmation = hasMatchingWorkspaceDeleteConfirmation(confirmationName, currentProject.name);
|
||||
|
||||
const handleDeleteDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setConfirmationName("");
|
||||
}
|
||||
setIsDeleteDialogOpen(open);
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!hasValidConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const deleteProjectResponse = await deleteProjectAction({
|
||||
projectId: currentProject.id,
|
||||
confirmationName,
|
||||
});
|
||||
|
||||
if (deleteProjectResponse?.data) {
|
||||
if (organizationProjects.length === 1) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
} else if (organizationProjects.length > 1) {
|
||||
// prevents changing of organization when deleting project
|
||||
const remainingProject = organizationProjects.find((project) => project.id !== currentProject.id);
|
||||
const productionEnvironment = remainingProject?.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
|
||||
}
|
||||
setIsDeleting(true);
|
||||
const deleteProjectResponse = await deleteProjectAction({ projectId: currentProject.id });
|
||||
if (deleteProjectResponse?.data) {
|
||||
if (organizationProjects.length === 1) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
} else if (organizationProjects.length > 1) {
|
||||
// prevents changing of organization when deleting project
|
||||
const remainingProjects = organizationProjects.filter((project) => project.id !== currentProject.id);
|
||||
const productionEnvironment = remainingProjects[0].environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
|
||||
}
|
||||
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
|
||||
logger.error({ errorMessage, projectId: currentProject.id }, "Workspace deletion action failed");
|
||||
toast.error(errorMessage);
|
||||
handleDeleteDialogOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, projectId: currentProject.id }, "Workspace deletion failed");
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -119,36 +91,13 @@ export const DeleteProjectRender = ({
|
||||
<DeleteDialog
|
||||
deleteWhat={t("environments.settings.domain.workspace")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={handleDeleteDialogOpenChange}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDeleteProject}
|
||||
text={t("environments.workspace.general.delete_workspace_confirmation", {
|
||||
projectName: truncate(currentProject.name, 30),
|
||||
})}
|
||||
isDeleting={isDeleting}
|
||||
disabled={!hasValidConfirmation}>
|
||||
<div className="py-5">
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await handleDeleteProject();
|
||||
}}>
|
||||
<label htmlFor="deleteProjectConfirmation">
|
||||
{t("environments.workspace.general.delete_workspace_confirmation_name", {
|
||||
projectName: currentProject.name,
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
value={confirmationName}
|
||||
onChange={(e) => setConfirmationName(e.target.value)}
|
||||
placeholder={currentProject.name}
|
||||
className="mt-2"
|
||||
type="text"
|
||||
id="deleteProjectConfirmation"
|
||||
name="deleteProjectConfirmation"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</DeleteDialog>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hasMatchingWorkspaceDeleteConfirmation } from "./delete-project-confirmation";
|
||||
|
||||
describe("workspace delete confirmation", () => {
|
||||
test("accepts an exact workspace name match", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("Acme Workspace", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts different casing", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("acme workspace", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts leading and trailing whitespace", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation(" Acme Workspace ", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an empty confirmation", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("", "Acme Workspace")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects mismatched confirmations", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("Other Workspace", "Acme Workspace")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
export const WORKSPACE_DELETE_CONFIRMATION_ERROR = "Workspace name confirmation does not match";
|
||||
|
||||
const normalizeWorkspaceNameConfirmation = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
export const hasMatchingWorkspaceDeleteConfirmation = (
|
||||
confirmationName: string,
|
||||
workspaceName: string
|
||||
): boolean => {
|
||||
return (
|
||||
normalizeWorkspaceNameConfirmation(confirmationName) === normalizeWorkspaceNameConfirmation(workspaceName)
|
||||
);
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AuthorizationError,
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR,
|
||||
deleteProjectWithConfirmation,
|
||||
getProjectIdForLogging,
|
||||
} from "./delete-project";
|
||||
import { WORKSPACE_DELETE_CONFIRMATION_ERROR } from "./delete-project-confirmation";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
getProject: vi.fn(),
|
||||
getUserProjects: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProject: mocks.getProject,
|
||||
getUserProjects: mocks.getUserProjects,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/projects/settings/lib/project", () => ({
|
||||
deleteProject: mocks.deleteProject,
|
||||
}));
|
||||
|
||||
const baseProject = {
|
||||
id: "cmproject000000000000000000",
|
||||
name: "Acme Workspace",
|
||||
organizationId: "cmorg00000000000000000000",
|
||||
};
|
||||
|
||||
const userId = "cmuser00000000000000000000";
|
||||
|
||||
const callDeleteProjectWithConfirmation = (input = {}) =>
|
||||
deleteProjectWithConfirmation({
|
||||
input: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: baseProject.name,
|
||||
...input,
|
||||
},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
});
|
||||
|
||||
describe("deleteProjectWithConfirmation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
mocks.getProject.mockResolvedValue(baseProject);
|
||||
mocks.getUserProjects.mockResolvedValue([baseProject, { ...baseProject, id: "cmproject2" }]);
|
||||
mocks.deleteProject.mockResolvedValue(baseProject);
|
||||
});
|
||||
|
||||
test("deletes a workspace when the confirmation name matches", async () => {
|
||||
const auditLoggingCtx = {};
|
||||
|
||||
const result = await deleteProjectWithConfirmation({
|
||||
input: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: "acme workspace",
|
||||
},
|
||||
userId,
|
||||
auditLoggingCtx,
|
||||
});
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId,
|
||||
organizationId: baseProject.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getUserProjects).toHaveBeenCalledWith(userId, baseProject.organizationId);
|
||||
expect(mocks.deleteProject).toHaveBeenCalledWith(baseProject.id);
|
||||
expect(auditLoggingCtx).toMatchObject({
|
||||
organizationId: baseProject.organizationId,
|
||||
projectId: baseProject.id,
|
||||
oldObject: baseProject,
|
||||
});
|
||||
expect(result).toEqual(baseProject);
|
||||
});
|
||||
|
||||
test("rejects invalid input before any project lookup", async () => {
|
||||
await expect(
|
||||
deleteProjectWithConfirmation({
|
||||
input: {},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
await expect(
|
||||
deleteProjectWithConfirmation({
|
||||
input: {},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
})
|
||||
).rejects.toThrow(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
|
||||
|
||||
expect(mocks.getProject).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when the confirmation name does not match", async () => {
|
||||
const deleteAttempt = callDeleteProjectWithConfirmation({ confirmationName: "Other Workspace" });
|
||||
|
||||
await expect(deleteAttempt).rejects.toThrow(InvalidInputError);
|
||||
await expect(deleteAttempt).rejects.toThrow(WORKSPACE_DELETE_CONFIRMATION_ERROR);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
expect(mocks.getUserProjects).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when the workspace cannot be found", async () => {
|
||||
mocks.getProject.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when authorization fails", async () => {
|
||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mocks.getUserProjects).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete the last available workspace", async () => {
|
||||
mocks.getUserProjects.mockResolvedValueOnce([baseProject]);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(OperationNotAllowedError);
|
||||
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rethrows downstream delete failures", async () => {
|
||||
const error = new Error("delete failed");
|
||||
mocks.deleteProject.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectIdForLogging", () => {
|
||||
test("returns the project id when present", () => {
|
||||
expect(getProjectIdForLogging({ projectId: baseProject.id })).toBe(baseProject.id);
|
||||
});
|
||||
|
||||
test("returns unknown when the project id is missing or invalid", () => {
|
||||
expect(getProjectIdForLogging({})).toBe("unknown");
|
||||
expect(getProjectIdForLogging({ projectId: 123 })).toBe("unknown");
|
||||
expect(getProjectIdForLogging(null)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getProject, getUserProjects } from "@/lib/project/service";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { deleteProject } from "@/modules/projects/settings/lib/project";
|
||||
import {
|
||||
WORKSPACE_DELETE_CONFIRMATION_ERROR,
|
||||
hasMatchingWorkspaceDeleteConfirmation,
|
||||
} from "./delete-project-confirmation";
|
||||
|
||||
const ZProjectDeleteAction = z.object({
|
||||
projectId: ZId,
|
||||
confirmationName: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR =
|
||||
"Workspace name confirmation is required to delete this workspace.";
|
||||
|
||||
export const parseProjectDeleteActionInput = (input: unknown) => {
|
||||
const parsedInput = ZProjectDeleteAction.safeParse(input);
|
||||
|
||||
if (!parsedInput.success) {
|
||||
throw new InvalidInputError(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
|
||||
}
|
||||
|
||||
return parsedInput.data;
|
||||
};
|
||||
|
||||
export const getProjectIdForLogging = (input: unknown) => {
|
||||
if (typeof input !== "object" || input === null || !("projectId" in input)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const projectId = input.projectId;
|
||||
|
||||
return typeof projectId === "string" ? projectId : "unknown";
|
||||
};
|
||||
|
||||
const assertMatchingWorkspaceDeleteConfirmation = (confirmationName: string, workspaceName: string) => {
|
||||
if (!hasMatchingWorkspaceDeleteConfirmation(confirmationName, workspaceName)) {
|
||||
throw new InvalidInputError(WORKSPACE_DELETE_CONFIRMATION_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
interface DeleteProjectWithConfirmationParams {
|
||||
input: unknown;
|
||||
userId: string;
|
||||
auditLoggingCtx: {
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
oldObject?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const deleteProjectWithConfirmation = async ({
|
||||
input,
|
||||
userId,
|
||||
auditLoggingCtx,
|
||||
}: DeleteProjectWithConfirmationParams) => {
|
||||
const { confirmationName, projectId } = parseProjectDeleteActionInput(input);
|
||||
const project = await getProject(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError("project", projectId);
|
||||
}
|
||||
|
||||
assertMatchingWorkspaceDeleteConfirmation(confirmationName, project.name);
|
||||
|
||||
const organizationId = project.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const availableProjects = await getUserProjects(userId, organizationId);
|
||||
|
||||
if (availableProjects.length <= 1) {
|
||||
throw new OperationNotAllowedError("You can't delete the last project in the environment.");
|
||||
}
|
||||
|
||||
auditLoggingCtx.organizationId = organizationId;
|
||||
auditLoggingCtx.projectId = projectId;
|
||||
auditLoggingCtx.oldObject = project;
|
||||
|
||||
return await deleteProject(projectId);
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -40,7 +39,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId);
|
||||
if (uploadResult.error) {
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
toast.error(uploadResult.error);
|
||||
return;
|
||||
}
|
||||
setLogoUrl(uploadResult.url);
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type TFunction } from "i18next";
|
||||
import toast from "react-hot-toast";
|
||||
import { FileUploadError } from "@/modules/storage/file-upload";
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
|
||||
export const getFileUploadErrorMessage = (error: FileUploadError, t: TFunction): string => {
|
||||
switch (error) {
|
||||
case FileUploadError.NO_FILE:
|
||||
return t("common.no_files_uploaded");
|
||||
case FileUploadError.INVALID_FILE_TYPE:
|
||||
return t("common.invalid_file_type");
|
||||
case FileUploadError.FILE_SIZE_EXCEEDED:
|
||||
return t("common.file_size_must_be_less_than_5_mb");
|
||||
case FileUploadError.INVALID_FILE_NAME:
|
||||
return t("common.invalid_file_name");
|
||||
case FileUploadError.UPLOAD_FAILED:
|
||||
default:
|
||||
return t("common.upload_failed");
|
||||
}
|
||||
};
|
||||
|
||||
export const showFileUploadErrorToast = (error: FileUploadError, t: TFunction): void => {
|
||||
if (error === FileUploadError.STORAGE_NOT_CONFIGURED) {
|
||||
showStorageNotConfiguredToast("notConfigured");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error === FileUploadError.STORAGE_UPLOAD_FAILED) {
|
||||
showStorageNotConfiguredToast("uploadUnavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(getFileUploadErrorMessage(error, t));
|
||||
};
|
||||
@@ -67,41 +67,7 @@ describe("fileUpload", () => {
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return STORAGE_NOT_CONFIGURED when signing API returns a storage configuration error", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
code: "internal_server_error",
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: "s3_credentials_error" },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_NOT_CONFIGURED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return INVALID_FILE_NAME when signing API rejects the file name", async () => {
|
||||
const file = createMockFile("----.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ details: { fileName: "Invalid file name" } }),
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.INVALID_FILE_NAME);
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
@@ -163,7 +129,7 @@ describe("fileUpload", () => {
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
@@ -195,34 +161,7 @@ describe("fileUpload", () => {
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return STORAGE_UPLOAD_FAILED when storage upload request throws", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
fileUrl: "/storage/test-env/public/file.jpg",
|
||||
presignedFields: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
setTimeout(() => {
|
||||
mockFileReader.onload();
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,48 +1,11 @@
|
||||
export enum FileUploadError {
|
||||
NO_FILE = "no_file",
|
||||
INVALID_FILE_TYPE = "invalid_file_type",
|
||||
FILE_SIZE_EXCEEDED = "file_size_exceeded",
|
||||
UPLOAD_FAILED = "upload_failed",
|
||||
INVALID_FILE_NAME = "invalid_file_name",
|
||||
STORAGE_NOT_CONFIGURED = "storage_not_configured",
|
||||
STORAGE_UPLOAD_FAILED = "storage_upload_failed",
|
||||
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
|
||||
INVALID_FILE_TYPE = "Please upload an image file.",
|
||||
FILE_SIZE_EXCEEDED = "File size must be less than 5 MB.",
|
||||
UPLOAD_FAILED = "Upload failed. Please try again.",
|
||||
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
|
||||
}
|
||||
|
||||
type UploadApiErrorResponse = {
|
||||
details?: {
|
||||
fileName?: string;
|
||||
storage_error_code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const storageConfigurationErrorCodes = new Set(["s3_credentials_error", "s3_client_error"]);
|
||||
|
||||
const parseUploadApiError = async (response: Response): Promise<UploadApiErrorResponse | undefined> => {
|
||||
try {
|
||||
return (await response.json()) as UploadApiErrorResponse;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getFileUploadErrorFromResponse = async (response: Response): Promise<FileUploadError> => {
|
||||
const json = await parseUploadApiError(response);
|
||||
|
||||
if (response.status === 400 && json?.details?.fileName) {
|
||||
return FileUploadError.INVALID_FILE_NAME;
|
||||
}
|
||||
|
||||
if (
|
||||
response.status >= 500 &&
|
||||
json?.details?.storage_error_code &&
|
||||
storageConfigurationErrorCodes.has(json.details.storage_error_code)
|
||||
) {
|
||||
return FileUploadError.STORAGE_NOT_CONFIGURED;
|
||||
}
|
||||
|
||||
return FileUploadError.UPLOAD_FAILED;
|
||||
};
|
||||
|
||||
export const toBase64 = (file: File) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -98,8 +61,18 @@ export const handleFileUpload = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
const json = (await response.json()) as { details?: { fileName?: string } };
|
||||
if (json.details?.fileName) {
|
||||
return {
|
||||
error: FileUploadError.INVALID_FILE_NAME,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: await getFileUploadErrorFromResponse(response),
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
@@ -134,24 +107,14 @@ export const handleFileUpload = async (
|
||||
};
|
||||
}
|
||||
|
||||
let uploadResponse: Response;
|
||||
|
||||
try {
|
||||
uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
body: formDataForS3,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error in uploading file: ", err);
|
||||
return {
|
||||
error: FileUploadError.STORAGE_UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
body: formDataForS3,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
return {
|
||||
error: FileUploadError.STORAGE_UPLOAD_FAILED,
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,9 +98,7 @@ describe("storage utils", () => {
|
||||
);
|
||||
const spyISE = vi
|
||||
.spyOn(responseMod.responses, "internalServerErrorResponse")
|
||||
.mockImplementation((msg: string, _public?: boolean, _cache?: string, details = {}) =>
|
||||
Response.json({ code: "internal_server_error", message: msg, details }, { status: 500 })
|
||||
);
|
||||
.mockImplementation((_msg: string, _public?: boolean) => new Response(null, { status: 500 }));
|
||||
|
||||
const { getErrorResponseFromStorageError } = await import("@/modules/storage/utils");
|
||||
|
||||
@@ -122,16 +120,8 @@ describe("storage utils", () => {
|
||||
// S3 related and Unknown -> 500
|
||||
const r500a = getErrorResponseFromStorageError({ code: StorageErrorCode.S3ClientError });
|
||||
expect(r500a.status).toBe(500);
|
||||
await expect(r500a.json()).resolves.toMatchObject({
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: StorageErrorCode.S3ClientError },
|
||||
});
|
||||
const r500b = getErrorResponseFromStorageError({ code: StorageErrorCode.S3CredentialsError });
|
||||
expect(r500b.status).toBe(500);
|
||||
await expect(r500b.json()).resolves.toMatchObject({
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: StorageErrorCode.S3CredentialsError },
|
||||
});
|
||||
const r500c = getErrorResponseFromStorageError({ code: StorageErrorCode.Unknown });
|
||||
expect(r500c.status).toBe(500);
|
||||
|
||||
|
||||
@@ -121,19 +121,9 @@ export const getErrorResponseFromStorageError = (
|
||||
case StorageErrorCode.InvalidInput:
|
||||
return responses.badRequestResponse("Invalid input", details, true);
|
||||
case StorageErrorCode.S3ClientError:
|
||||
return responses.internalServerErrorResponse(
|
||||
"File storage is not configured correctly. Please check your file upload settings.",
|
||||
true,
|
||||
"private, no-store",
|
||||
{ storage_error_code: error.code }
|
||||
);
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
case StorageErrorCode.S3CredentialsError:
|
||||
return responses.internalServerErrorResponse(
|
||||
"File storage is not configured correctly. Please check your file upload settings.",
|
||||
true,
|
||||
"private, no-store",
|
||||
{ storage_error_code: error.code }
|
||||
);
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
case StorageErrorCode.Unknown:
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
default: {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
||||
@@ -82,7 +81,7 @@ export const LogoSettingsCard = ({
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId);
|
||||
if (uploadResult.error) {
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
toast.error(t("common.upload_failed"));
|
||||
return;
|
||||
}
|
||||
setLogoUrl(uploadResult.url);
|
||||
|
||||
@@ -2,16 +2,17 @@ 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 [singleUseId, setSingleUseId] = useState<string>();
|
||||
const [singleUseLinkParams, setSingleUseLinkParams] = useState<TSurveySingleUseLinkParams>();
|
||||
|
||||
const refreshSingleUseId = useCallback(async (): Promise<string | undefined> => {
|
||||
const refreshSingleUseId = useCallback(async (): Promise<TSurveySingleUseLinkParams | undefined> => {
|
||||
if (isReadOnly || !survey.singleUse?.enabled) {
|
||||
// If read‑only or singleUse disabled, just clear and bail out
|
||||
setSingleUseId(undefined);
|
||||
setSingleUseLinkParams(undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -22,7 +23,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
|
||||
});
|
||||
|
||||
if (response?.data?.length) {
|
||||
setSingleUseId(response.data[0]);
|
||||
setSingleUseLinkParams(response.data[0]);
|
||||
return response.data[0];
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(response));
|
||||
@@ -31,7 +32,8 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
|
||||
}, [survey, isReadOnly]);
|
||||
|
||||
return {
|
||||
singleUseId: isReadOnly ? undefined : singleUseId,
|
||||
singleUseId: isReadOnly ? undefined : singleUseLinkParams?.suId,
|
||||
singleUseToken: isReadOnly ? undefined : singleUseLinkParams?.suToken,
|
||||
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ interface SurveyRendererProps {
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
suId?: string;
|
||||
suToken?: string;
|
||||
};
|
||||
singleUseId?: string;
|
||||
singleUseResponse?: Pick<Response, "id" | "finished">;
|
||||
@@ -117,6 +118,7 @@ export const renderSurvey = async ({
|
||||
return (
|
||||
<VerifyEmail
|
||||
singleUseId={searchParams.suId ?? ""}
|
||||
singleUseToken={searchParams.suToken}
|
||||
survey={survey}
|
||||
languageCode={getLanguageCode(langParam, survey)}
|
||||
styling={project.styling}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface VerifyEmailProps {
|
||||
survey: TSurvey;
|
||||
isErrorComponent?: boolean;
|
||||
singleUseId?: string;
|
||||
singleUseToken?: string;
|
||||
languageCode: string;
|
||||
styling: TProjectStyling;
|
||||
locale: TUserLocale;
|
||||
@@ -40,6 +41,7 @@ export const VerifyEmail = ({
|
||||
survey,
|
||||
isErrorComponent,
|
||||
singleUseId,
|
||||
singleUseToken,
|
||||
languageCode,
|
||||
styling,
|
||||
locale,
|
||||
@@ -94,6 +96,7 @@ export const VerifyEmail = ({
|
||||
email: email,
|
||||
surveyName: localSurvey.name,
|
||||
suId: singleUseId ?? "",
|
||||
suToken: singleUseToken,
|
||||
locale,
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ContactSurveyPageProps {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
suId?: string;
|
||||
suToken?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
@@ -87,7 +88,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
|
||||
const t = await getTranslate();
|
||||
const { jwt } = params;
|
||||
const { preview, suId } = searchParams;
|
||||
const { preview, suId, suToken } = searchParams;
|
||||
|
||||
const result = verifyContactSurveyToken(jwt);
|
||||
if (!result.ok) {
|
||||
@@ -127,7 +128,12 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
let singleUseId: string | undefined = undefined;
|
||||
|
||||
if (isSingleUseSurvey) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(
|
||||
suId,
|
||||
isSingleUseSurveyEncrypted,
|
||||
survey.id,
|
||||
suToken
|
||||
);
|
||||
if (!validatedSingleUseId) {
|
||||
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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(),
|
||||
}));
|
||||
@@ -10,6 +13,9 @@ 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);
|
||||
@@ -62,6 +68,7 @@ describe("getEmailVerificationDetails", () => {
|
||||
|
||||
describe("checkAndValidateSingleUseId", () => {
|
||||
const mockedValidateSurveySingleUseId = vi.mocked(validateSurveySingleUseId);
|
||||
const mockedValidateSurveySingleUseLinkParams = vi.mocked(validateSurveySingleUseLinkParams);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -81,20 +88,38 @@ describe("checkAndValidateSingleUseId", () => {
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns suid as-is when isEncrypted is false", () => {
|
||||
test("returns null when isEncrypted is false and surveyId is missing", () => {
|
||||
const testSuid = "plain-suid-123";
|
||||
const result = checkAndValidateSingleUseId(testSuid, false);
|
||||
|
||||
expect(result).toBe(testSuid);
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
expect(mockedValidateSurveySingleUseLinkParams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns suid as-is when isEncrypted is not provided (defaults to false)", () => {
|
||||
test("returns signed suid when isEncrypted is false and signature validation succeeds", () => {
|
||||
const testSuid = "plain-suid-123";
|
||||
const result = checkAndValidateSingleUseId(testSuid);
|
||||
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", () => {
|
||||
const testSuid = "plain-suid-123";
|
||||
mockedValidateSurveySingleUseLinkParams.mockReturnValueOnce(null);
|
||||
const result = checkAndValidateSingleUseId(testSuid, false, "survey-1");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns validated suid when isEncrypted is true and validation succeeds", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -27,7 +28,12 @@ export const getEmailVerificationDetails = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false): string | null => {
|
||||
export const checkAndValidateSingleUseId = (
|
||||
suid?: string,
|
||||
isEncrypted = false,
|
||||
surveyId?: string,
|
||||
suToken?: string
|
||||
): string | null => {
|
||||
if (!suid?.trim()) return null;
|
||||
|
||||
if (isEncrypted) {
|
||||
@@ -36,5 +42,17 @@ export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false):
|
||||
return validatedSingleUseId;
|
||||
}
|
||||
|
||||
return suid;
|
||||
if (!surveyId) return null;
|
||||
|
||||
try {
|
||||
return validateSurveySingleUseLinkParams({
|
||||
surveyId,
|
||||
suId: suid,
|
||||
suToken,
|
||||
isEncrypted,
|
||||
decrypt: (encryptedSingleUseId) => validateSurveySingleUseId(encryptedSingleUseId) ?? "",
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ interface LinkSurveyPageProps {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
suId?: string;
|
||||
suToken?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
@@ -84,6 +85,7 @@ 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;
|
||||
@@ -91,7 +93,12 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
let singleUseId: string | undefined = undefined;
|
||||
|
||||
if (isSingleUseSurvey) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(
|
||||
suId,
|
||||
isSingleUseSurveyEncrypted,
|
||||
survey.id,
|
||||
suToken
|
||||
);
|
||||
if (!validatedSingleUseId) {
|
||||
// Need to fetch project for error page - fetch environmentContext for it
|
||||
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
getOrganizationIdFromSurveyId,
|
||||
getProjectIdFromSurveyId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import {
|
||||
generateSurveySingleUseLinkParams,
|
||||
generateSurveySingleUseLinkParamsList,
|
||||
} 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";
|
||||
@@ -93,11 +96,16 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
|
||||
})
|
||||
);
|
||||
|
||||
const ZGenerateSingleUseIdAction = z.object({
|
||||
surveyId: z.cuid2(),
|
||||
isEncrypted: z.boolean(),
|
||||
count: z.number().min(1).max(5000).prefault(1),
|
||||
});
|
||||
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",
|
||||
});
|
||||
|
||||
export const generateSingleUseIdsAction = authenticatedActionClient
|
||||
.inputSchema(ZGenerateSingleUseIdAction)
|
||||
@@ -118,5 +126,13 @@ export const generateSingleUseIdsAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
|
||||
if (parsedInput.singleUseId) {
|
||||
return [generateSurveySingleUseLinkParams(parsedInput.surveyId, false, parsedInput.singleUseId)];
|
||||
}
|
||||
|
||||
return generateSurveySingleUseLinkParamsList(
|
||||
parsedInput.count,
|
||||
parsedInput.surveyId,
|
||||
parsedInput.isEncrypted
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { FileUploadError, handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
@@ -94,10 +93,10 @@ export const FileInput = ({
|
||||
|
||||
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
|
||||
const firstError = uploadedFiles.find((f) => f.error)?.error;
|
||||
if (uploadedFiles.length === 0) {
|
||||
if (firstError === FileUploadError.INVALID_FILE_NAME) {
|
||||
toast.error(t("common.invalid_file_name"));
|
||||
} else if (uploadedFiles.length === 0) {
|
||||
toast.error(t("common.no_files_uploaded"));
|
||||
} else if (firstError) {
|
||||
showFileUploadErrorToast(firstError, t);
|
||||
} else {
|
||||
toast.error(t("common.some_files_failed_to_upload"));
|
||||
}
|
||||
@@ -168,10 +167,10 @@ export const FileInput = ({
|
||||
|
||||
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
|
||||
const firstError = uploadedFiles.find((f) => f.error)?.error;
|
||||
if (uploadedFiles.length === 0) {
|
||||
if (firstError === FileUploadError.INVALID_FILE_NAME) {
|
||||
toast.error(t("common.invalid_file_name"));
|
||||
} else if (uploadedFiles.length === 0) {
|
||||
toast.error(t("common.no_files_uploaded"));
|
||||
} else if (firstError) {
|
||||
showFileUploadErrorToast(firstError, t);
|
||||
} else {
|
||||
toast.error(t("common.some_files_failed_to_upload"));
|
||||
}
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface StorageNotConfiguredToastProps {
|
||||
variant?: "notConfigured" | "uploadUnavailable";
|
||||
}
|
||||
|
||||
export const StorageNotConfiguredToast = ({ variant = "notConfigured" }: StorageNotConfiguredToastProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export const StorageNotConfiguredToast = () => {
|
||||
return (
|
||||
<div className="flex w-fit !max-w-md items-center justify-center gap-2">
|
||||
<span className="text-slate-900">
|
||||
{variant === "uploadUnavailable"
|
||||
? t("common.file_upload_service_unavailable")
|
||||
: t("common.file_storage_not_set_up")}
|
||||
</span>
|
||||
<span className="text-slate-900">File storage not set up</span>
|
||||
<a
|
||||
className="text-slate-900 underline"
|
||||
href="https://formbricks.com/docs/self-hosting/configuration/file-uploads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("common.learn_more")}
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { StorageNotConfiguredToast } from "../index";
|
||||
|
||||
export const showStorageNotConfiguredToast = (
|
||||
variant: "notConfigured" | "uploadUnavailable" = "notConfigured"
|
||||
) => {
|
||||
return toast.error(() => <StorageNotConfiguredToast variant={variant} />, {
|
||||
export const showStorageNotConfiguredToast = () => {
|
||||
return toast.error(() => <StorageNotConfiguredToast />, {
|
||||
id: "storage-not-configured-toast",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
const createEnvironmentData = (type: "development" | "production") => ({
|
||||
type,
|
||||
attributeKeys: {
|
||||
create: [
|
||||
{
|
||||
name: "Email",
|
||||
key: "email",
|
||||
isUnique: true,
|
||||
type: "default" as const,
|
||||
},
|
||||
{
|
||||
name: "First Name",
|
||||
key: "firstName",
|
||||
isUnique: false,
|
||||
type: "default" as const,
|
||||
},
|
||||
{
|
||||
name: "Last Name",
|
||||
key: "lastName",
|
||||
isUnique: false,
|
||||
type: "default" as const,
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
key: "userId",
|
||||
isUnique: true,
|
||||
type: "default" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const getProjectForEmail = async (email: string) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
projects: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const membership = user?.memberships[0];
|
||||
const project = membership?.organization.projects[0];
|
||||
const productionEnvironment = project?.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
|
||||
if (!membership || !project || !productionEnvironment) {
|
||||
throw new Error(`Project not found for email: ${email}`);
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId: membership.organizationId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
productionEnvironmentId: productionEnvironment.id,
|
||||
};
|
||||
};
|
||||
|
||||
test("requires project name confirmation before deleting a project", async ({ page, users }) => {
|
||||
const timestamp = Date.now();
|
||||
const email = `project-delete-${timestamp}@example.com`;
|
||||
const projectName = `Delete Project ${timestamp}`;
|
||||
const remainingProjectName = `Remaining Project ${timestamp}`;
|
||||
const user = await users.create({
|
||||
email,
|
||||
name: `project-delete-${timestamp}`,
|
||||
projectName,
|
||||
});
|
||||
const project = await getProjectForEmail(email);
|
||||
const remainingProject = await prisma.project.create({
|
||||
data: {
|
||||
name: remainingProjectName,
|
||||
organizationId: project.organizationId,
|
||||
environments: {
|
||||
create: [createEnvironmentData("development"), createEnvironmentData("production")],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const remainingProductionEnvironment = await prisma.environment.findFirst({
|
||||
where: {
|
||||
projectId: remainingProject.id,
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const remainingProductionEnvironmentId = remainingProductionEnvironment?.id;
|
||||
|
||||
if (!remainingProductionEnvironmentId) {
|
||||
throw new Error("Remaining project production environment not found");
|
||||
}
|
||||
|
||||
await user.login();
|
||||
await page.goto(`/environments/${project.productionEnvironmentId}/workspace/general`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeDisabled();
|
||||
|
||||
await page.locator("#deleteProjectConfirmation").fill(project.projectName.toUpperCase());
|
||||
await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Workspace deleted successfully", { exact: true })).toBeVisible();
|
||||
await page.waitForURL(new RegExp(`/environments/${remainingProductionEnvironmentId}/surveys`));
|
||||
await expect.poll(async () => prisma.project.findUnique({ where: { id: project.projectId } })).toBeNull();
|
||||
});
|
||||
@@ -9,7 +9,7 @@ icon: "user"
|
||||
In self-hosted Formbricks, user management and authentication can be customized using environment variables. By default, self-hosted instances have user signup disabled, and only organization owners or admins can invite new users. The behavior of the authentication and invitation flow can be further controlled using the following environment variables:
|
||||
|
||||
- `AUTH_SKIP_INVITE_FOR_SSO`
|
||||
- `AUTH_SSO_DEFAULT_TEAM_ID`
|
||||
- `AUTH_DEFAULT_TEAM_ID`
|
||||
|
||||
## License Requirement for Role Management and SSO Behavior
|
||||
|
||||
@@ -31,7 +31,7 @@ In self-hosted Formbricks, user management and authentication can be customized
|
||||
- Set this to `1` if you want to allow anyone with access to your SSO provider to join your Formbricks instance without a manual invite.
|
||||
- Keep it at `0` for stricter access control, where only invited users can join, regardless of SSO.
|
||||
|
||||
### `AUTH_SSO_DEFAULT_TEAM_ID`
|
||||
### `AUTH_DEFAULT_TEAM_ID`
|
||||
|
||||
- **Type:** String (Team ID, a valid cuid)
|
||||
- **Default:** None (must be set if you want to use default team assignment)
|
||||
@@ -49,7 +49,7 @@ In self-hosted Formbricks, user management and authentication can be customized
|
||||
AUTH_SKIP_INVITE_FOR_SSO=1
|
||||
|
||||
# Automatically assign new users to this team
|
||||
AUTH_SSO_DEFAULT_TEAM_ID=team-123
|
||||
AUTH_DEFAULT_TEAM_ID=team-123
|
||||
```
|
||||
|
||||
Refer to the [Environment Variables documentation](./configuration/environment-variables) for a full list and details.
|
||||
|
||||
@@ -25,7 +25,6 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_BUCKET_NAME | S3 bucket name for data storage. Formbricks enables S3 storage when this is set. | optional (required if S3 is enabled) | |
|
||||
| S3_ENDPOINT_URL | Endpoint for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_FORCE_PATH_STYLE | Set to `1` to force path-style S3 URLs. Required for S3-compatible storage (MinIO, RustFS, LocalStack). Leave unset or `0` for standard AWS S3. | optional | 0 |
|
||||
| SAML_DATABASE_URL | Database URL for SAML. | optional | postgres://postgres:@localhost:5432/formbricks-saml |
|
||||
| PRIVACY_URL | URL for privacy policy. | optional | |
|
||||
| TERMS_URL | URL for terms of service. | optional | |
|
||||
@@ -89,10 +88,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| OTEL_TRACES_SAMPLER_ARG | Sampling argument used by ratio-based samplers (`0` to `1`). | optional | |
|
||||
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
|
||||
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
|
||||
| AUTH_SSO_DEFAULT_TEAM_ID | ID of the team that new SSO users are automatically added to. The owning organization is derived from this team. Must be set together with `AUTH_SKIP_INVITE_FOR_SSO=1` for auto-provisioning to work. | optional | |
|
||||
| AUTH_SKIP_INVITE_FOR_SSO | Set to `1` to allow SSO users to create an account without a manual invite. Keep unset (or `0`) for stricter access control where only invited users can join. | optional | 0 |
|
||||
| HTTP_PROXY | HTTP proxy URL used for outbound requests (e.g., license checks). When both are set, `HTTPS_PROXY` takes precedence. | optional | |
|
||||
| HTTPS_PROXY | HTTPS proxy URL used for outbound requests. Takes precedence over `HTTP_PROXY`. | optional | |
|
||||
| DEFAULT_TEAM_ID | Default team ID for new users. | optional | |
|
||||
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional | |
|
||||
| SENTRY_ENVIRONMENT | Set this to identify the environment in Sentry | optional | |
|
||||
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional | |
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "يمكن تحميل ملف واحد فقط في المرة الواحدة.",
|
||||
"placeholder_text": "انقر أو اسحب لرفع الملفات",
|
||||
"upload_failed": "فشل التحميل! يرجى المحاولة مرة أخرى.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "جارٍ الرفع...",
|
||||
"you_can_only_upload_a_maximum_of_files": "يمكنك تحميل {FILE_LIMIT} ملفات كحد أقصى."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Du kan kun uploade én fil ad gangen.",
|
||||
"placeholder_text": "Klik eller træk for at uploade filer",
|
||||
"upload_failed": "Upload mislykkedes! Prøv venligst igen.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Uploader...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan maksimalt uploade {FILE_LIMIT} filer."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
|
||||
"placeholder_text": "Klicke oder ziehe Dateien hierher zum Hochladen",
|
||||
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
|
||||
"placeholder_text": "Click or drag to upload files",
|
||||
"upload_failed": "Upload failed! Please try again.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Uploading...",
|
||||
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Solo se puede subir un archivo a la vez.",
|
||||
"placeholder_text": "Haz clic o arrastra para subir archivos",
|
||||
"upload_failed": "¡Subida fallida! Por favor, inténtalo de nuevo.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Subiendo...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Solo puedes subir un máximo de {FILE_LIMIT} archivos."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Korraga saab üles laadida ainult ühe faili.",
|
||||
"placeholder_text": "Klõpsa või lohista failide üleslaadimiseks",
|
||||
"upload_failed": "Üleslaadimine ebaõnnestus! Palun proovi uuesti.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Üleslaadimine...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Un seul fichier peut être téléchargé à la fois.",
|
||||
"placeholder_text": "Cliquez ou glissez pour télécharger des fichiers",
|
||||
"upload_failed": "Échec du téléchargement ! Veuillez réessayer.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Téléchargement en cours...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Vous ne pouvez télécharger qu'un maximum de {FILE_LIMIT} fichiers."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "एक समय में केवल एक फ़ाइल अपलोड की जा सकती है।",
|
||||
"placeholder_text": "फ़ाइलें अपलोड करने के लिए क्लिक करें या ड्रैग करें",
|
||||
"upload_failed": "अपलोड विफल! कृपया पुनः प्रयास करें।",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "अपलोड हो रहा है...",
|
||||
"you_can_only_upload_a_maximum_of_files": "आप अधिकतम {FILE_LIMIT} फ़ाइलें ही अपलोड कर सकते हैं।"
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.",
|
||||
"placeholder_text": "Kattintson vagy húzza ide a fájlok feltöltéséhez",
|
||||
"upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Feltöltés…",
|
||||
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "È possibile caricare solo un file alla volta.",
|
||||
"placeholder_text": "Clicca o trascina per caricare i file",
|
||||
"upload_failed": "Caricamento fallito! Riprova.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Caricamento in corso...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Puoi caricare un massimo di {FILE_LIMIT} file."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "一度にアップロードできるファイルは1つだけです。",
|
||||
"placeholder_text": "クリックまたはドラッグしてファイルをアップロード",
|
||||
"upload_failed": "アップロードに失敗しました!もう一度お試しください。",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "アップロード中...",
|
||||
"you_can_only_upload_a_maximum_of_files": "アップロードできるファイルは最大{FILE_LIMIT}個までです。"
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Er kan slechts één bestand tegelijk worden geüpload.",
|
||||
"placeholder_text": "Klik of sleep bestanden om te uploaden",
|
||||
"upload_failed": "Uploaden mislukt! Probeer het opnieuw.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Uploaden...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Je kunt maximaal {FILE_LIMIT} bestanden uploaden."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Apenas um arquivo pode ser carregado de cada vez.",
|
||||
"placeholder_text": "Clique ou arraste para enviar ficheiros",
|
||||
"upload_failed": "Falha no carregamento! Por favor, tente novamente.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "A enviar...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Você só pode carregar um máximo de {FILE_LIMIT} arquivos."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Poți încărca doar un singur fișier odată.",
|
||||
"placeholder_text": "Apasă sau trage pentru a încărca fișiere",
|
||||
"upload_failed": "Încărcarea a eșuat! Te rugăm să încerci din nou.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Se încarcă...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Poți încărca un număr maxim de {FILE_LIMIT} fișiere."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Можно загрузить только один файл за раз.",
|
||||
"placeholder_text": "Нажмите или перетащите файлы для загрузки",
|
||||
"upload_failed": "Ошибка загрузки! Пожалуйста, попробуйте снова.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Загрузка...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Вы можете загрузить максимум {FILE_LIMIT} файлов."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
|
||||
"placeholder_text": "Klicka eller dra för att ladda upp filer",
|
||||
"upload_failed": "Uppladdning misslyckades! Försök igen.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Laddar upp...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Aynı anda yalnızca bir dosya yüklenebilir.",
|
||||
"placeholder_text": "Dosyaları yüklemek için tıklayın veya sürükleyin",
|
||||
"upload_failed": "Yükleme başarısız oldu! Lütfen tekrar deneyin.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Yükleniyor...",
|
||||
"you_can_only_upload_a_maximum_of_files": "En fazla {FILE_LIMIT} dosya yükleyebilirsiniz."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Bir vaqtning o'zida faqat bitta fayl yuklanishi mumkin.",
|
||||
"placeholder_text": "Fayllarni yuklash uchun bosing yoki sudrab olib keling",
|
||||
"upload_failed": "Yuklash muvaffaqiyatsiz tugadi! Iltimos, qayta urinib ko'ring.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Yuklanmoqda...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Siz faqat {FILE_LIMIT} ta faylni maksimal yuklashingiz mumkin."
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"only_one_file_can_be_uploaded_at_a_time": "一次只能上传一个文件。",
|
||||
"placeholder_text": "点击或拖拽上传文件",
|
||||
"upload_failed": "上传失败!请重试。",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "上传中...",
|
||||
"you_can_only_upload_a_maximum_of_files": "您最多只能上传 {FILE_LIMIT} 个文件。"
|
||||
},
|
||||
|
||||
@@ -288,8 +288,6 @@ export function FileUploadElement({
|
||||
setFileErrorMessage(err.message);
|
||||
} else if (err?.name === "InvalidFileNameError") {
|
||||
setFileErrorMessage(t("errors.file_input.upload_failed"));
|
||||
} else if (err?.name === "StorageNotConfiguredError" || err?.name === "StorageUploadFailedError") {
|
||||
setFileErrorMessage(t("errors.file_input.upload_service_unavailable"));
|
||||
} else {
|
||||
setFileErrorMessage(t("errors.file_input.upload_failed"));
|
||||
}
|
||||
|
||||
@@ -202,26 +202,6 @@ describe("ApiClient", () => {
|
||||
).rejects.toThrow("Invalid file name");
|
||||
});
|
||||
|
||||
test("throws StorageNotConfiguredError if signing fails because storage is not configured", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
code: "internal_server_error",
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: "s3_client_error" },
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(() =>
|
||||
client.uploadFile({
|
||||
base64: "data:image/jpeg;base64,abcd",
|
||||
name: "test.jpg",
|
||||
type: "image/jpeg",
|
||||
})
|
||||
).rejects.toMatchObject({ name: "StorageNotConfiguredError" });
|
||||
});
|
||||
|
||||
test("throws an error if actual upload fails", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -250,31 +230,6 @@ describe("ApiClient", () => {
|
||||
).rejects.toThrow("Upload failed with status: 500");
|
||||
});
|
||||
|
||||
test("throws StorageUploadFailedError if actual upload request fails before response", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://fake-s3-url.com",
|
||||
fileUrl: "https://fake-file-url.com",
|
||||
presignedFields: { policy: "test" },
|
||||
signingData: null,
|
||||
updatedFileName: "test.jpg",
|
||||
},
|
||||
}),
|
||||
} as unknown as Response)
|
||||
.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(() =>
|
||||
client.uploadFile({
|
||||
base64: "data:image/jpeg;base64,abcd",
|
||||
name: "test.jpg",
|
||||
type: "image/jpeg",
|
||||
})
|
||||
).rejects.toMatchObject({ name: "StorageUploadFailedError" });
|
||||
});
|
||||
|
||||
test('throws "Error uploading file" if base64 is invalid', async () => {
|
||||
// Mock the initial "signing" fetch to succeed
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
|
||||
@@ -23,23 +23,6 @@ type TResponseCreateResponse = {
|
||||
|
||||
type TResponseUpdateResponse = Record<string, unknown> & TResponseQuota;
|
||||
|
||||
type TUploadApiErrorResponse = ApiErrorResponse & {
|
||||
details?: ApiErrorResponse["details"] & {
|
||||
storage_error_code?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const storageConfigurationErrorCodes = new Set(["s3_credentials_error", "s3_client_error"]);
|
||||
|
||||
const parseUploadErrorResponse = async (response: Response): Promise<TUploadApiErrorResponse | undefined> => {
|
||||
try {
|
||||
return (await response.json()) as TUploadApiErrorResponse;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Simple API client using fetch
|
||||
export class ApiClient {
|
||||
readonly appUrl: string;
|
||||
@@ -138,22 +121,13 @@ export class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const json = await parseUploadErrorResponse(response);
|
||||
|
||||
if (response.status === 400 && json?.details?.fileName) {
|
||||
const err = new Error("Invalid file name");
|
||||
err.name = "InvalidFileNameError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (
|
||||
response.status >= 500 &&
|
||||
json?.details?.storage_error_code &&
|
||||
storageConfigurationErrorCodes.has(json.details.storage_error_code)
|
||||
) {
|
||||
const err = new Error("File upload service is not configured");
|
||||
err.name = "StorageNotConfiguredError";
|
||||
throw err;
|
||||
if (response.status === 400) {
|
||||
const json = (await response.json()) as ApiErrorResponse;
|
||||
if (json.details?.fileName) {
|
||||
const err = new Error("Invalid file name");
|
||||
err.name = "InvalidFileNameError";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Upload failed with status: ${String(response.status)}`);
|
||||
@@ -199,9 +173,7 @@ export class ApiClient {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error uploading file", err);
|
||||
const error = new Error("File upload service is unavailable");
|
||||
error.name = "StorageUploadFailedError";
|
||||
throw error;
|
||||
throw new Error("Network error while uploading file");
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
@@ -213,9 +185,7 @@ export class ApiClient {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error = new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
|
||||
error.name = "StorageUploadFailedError";
|
||||
throw error;
|
||||
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
|
||||
}
|
||||
|
||||
return fileUrl;
|
||||
|
||||
@@ -6,6 +6,7 @@ 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(),
|
||||
|
||||
Reference in New Issue
Block a user