Compare commits

..

20 Commits

Author SHA1 Message Date
Victor Santos
fdfd1bae70 refactoring 2025-07-24 11:27:44 -03:00
ompharate
09404539d3 fix: prevent email verification checks in preview mode 2025-07-23 20:25:43 +05:30
Piyush Gupta
ee20af54c3 feat: adds an underline option in the rich text editor (#6274)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-23 10:54:05 +00:00
Johannes
d08ec4c9ab docs: Fix domain split docs (#6300)
Co-authored-by: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com>
2025-07-23 03:54:53 -07:00
Piyush Gupta
891c83e232 fix: CTA question button URL validation (#6284)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-23 05:48:18 +00:00
Johannes
0b02b00b72 fix: link input length and accessibility error (#6283)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-23 05:01:16 +00:00
Harsh Thakur
a217cdd501 fix: email embed preview spacing issue (#6262) 2025-07-22 17:14:07 +00:00
om pharate
ebe50a4821 fix: render copy link button based on single use survey (#6288) 2025-07-22 16:31:54 +00:00
Johannes
cb68d9defc chore: enable blank issue (#6291) 2025-07-22 10:02:49 -07:00
Victor Hugo dos Santos
c42a706789 fix: Experimental workflow package.json version update (#6287) 2025-07-22 15:19:38 +00:00
Anshuman Pandey
3803111b19 fix: fixes personalized links when single use id is enabled (#6270) 2025-07-22 12:08:45 +00:00
Dhruwang Jariwala
30fdcff737 feat: reset survey (#6267) 2025-07-22 12:04:26 +00:00
Dhruwang Jariwala
e83cfa85a4 fix: github annotations (#6240) 2025-07-22 10:38:34 +00:00
Piyush Gupta
eee9ee8995 chore: Replaces Unkey and Update rate limiting in the management API v2. (#6273) 2025-07-22 09:33:29 +00:00
Dhruwang Jariwala
ed89f12af8 chore: rate limiting for server actions (#6271) 2025-07-22 09:18:12 +00:00
Piyush Gupta
f043314537 fix: required action revert logic (#6269) 2025-07-22 04:10:09 +00:00
Victor Hugo dos Santos
2ce842dd8d chore: updated SAML SSO docs (#6280) 2025-07-22 04:09:11 +00:00
Johannes
43b43839c5 chore: auto-add bug to eng project (#6277) 2025-07-21 08:33:27 -07:00
Piyush Gupta
8b6e3fec37 fix: response filters icons and text (#6266) 2025-07-21 08:48:10 +00:00
Anshuman Pandey
31bcf98779 fix: fixes PIN 4 digit length error (#6265) 2025-07-21 07:30:03 +00:00
111 changed files with 2892 additions and 1296 deletions

View File

@@ -194,9 +194,6 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=

View File

@@ -1,6 +1,7 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug
projects: "formbricks/8"
labels: ["bug"]
body:
- type: textarea

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Questions
url: https://github.com/formbricks/formbricks/discussions

View File

@@ -76,12 +76,9 @@ jobs:
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
env:
VERSION: ${{ env.VERSION }}
run: |
cd ./apps/web
npm version $VERSION --no-git-tag-version
echo "Updated version to: $(npm pkg get version)"
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set Sentry environment in .env
run: |

View File

@@ -7,6 +7,7 @@ import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
@@ -35,6 +36,7 @@ export const EnvironmentContextWrapper = ({
() => ({
environment,
project,
organizationId: project.organizationId,
}),
[environment, project]
);

View File

@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
locale: TUserLocale;
}
const tableHeaders = [
"common.survey",
"environments.integrations.airtable.table_name",
"common.questions",
"common.updated_at",
];
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslate();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{t(header)}
{header}
</div>
))}
</div>

View File

@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
AuthenticationError,
AuthorizationError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
TUserPersonalInfoUpdateInput,
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
)
);
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),

View File

@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
@@ -178,6 +183,7 @@ describe("ResponsesPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
});
@@ -206,6 +212,8 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
publicDomain: mockPublicDomain,
responseCount: 10,
displayCount: 5,
}),
undefined
);

View File

@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -40,6 +41,7 @@ const Page = async (props) => {
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
@@ -56,6 +58,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

View File

@@ -18,6 +18,7 @@ import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -202,6 +203,61 @@ export const deleteResultShareUrlAction = authenticatedActionClient
)
);
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
)
);
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,
});

View File

@@ -33,6 +33,18 @@ vi.mock("@tolgee/react", () => ({
if (key === "environments.surveys.edit.caution_edit_duplicate") {
return "Duplicate & Edit";
}
if (key === "environments.surveys.summary.reset_survey") {
return "Reset survey";
}
if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") {
return "Delete all existing responses and displays";
}
if (key === "environments.surveys.summary.reset_survey_warning") {
return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone.";
}
if (key === "environments.surveys.summary.survey_reset_successfully") {
return "Survey reset successfully! 5 responses and 3 displays were deleted.";
}
return key;
},
}),
@@ -40,12 +52,14 @@ vi.mock("@tolgee/react", () => ({
// Mock Next.js hooks
const mockPush = vi.fn();
const mockRefresh = vi.fn();
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
const mockSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
refresh: mockRefresh,
}),
usePathname: () => mockPathname,
useSearchParams: () => mockSearchParams,
@@ -69,6 +83,10 @@ vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn(),
}));
vi.mock("../actions", () => ({
resetSurveyAction: vi.fn(),
}));
// Mock the useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: vi.fn(() => ({
@@ -147,6 +165,34 @@ vi.mock("@/modules/ui/components/badge", () => ({
),
}));
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
ConfirmationModal: ({
open,
setOpen,
title,
text,
buttonText,
onConfirm,
buttonVariant,
buttonLoading,
}: any) => (
<div
data-testid="confirmation-modal"
data-open={open}
data-loading={buttonLoading}
data-variant={buttonVariant}>
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-text">{text}</div>
<button type="button" onClick={onConfirm} data-testid="confirm-button">
{buttonText}
</button>
<button type="button" onClick={() => setOpen(false)} data-testid="cancel-button">
Cancel
</button>
</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, className }: any) => (
<button type="button" data-testid="button" onClick={onClick} className={className}>
@@ -178,9 +224,17 @@ vi.mock("@/modules/ui/components/iconbar", () => ({
vi.mock("lucide-react", () => ({
BellRing: () => <svg data-testid="bell-ring-icon" />,
Eye: () => <svg data-testid="eye-icon" />,
ListRestart: () => <svg data-testid="list-restart-icon" />,
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
useEnvironment: vi.fn(() => ({
organizationId: "test-organization-id",
project: { id: "test-project-id" },
})),
}));
// Mock data
const mockEnvironment: TEnvironment = {
id: "test-env-id",
@@ -270,6 +324,7 @@ const defaultProps = {
user: mockUser,
publicDomain: "https://example.com",
responseCount: 0,
displayCount: 0,
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
@@ -286,19 +341,19 @@ describe("SurveyAnalysisCTA", () => {
});
test("renders share survey button", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByText("Share survey")).toBeInTheDocument();
});
test("renders success message component", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("success-message")).toBeInTheDocument();
});
test("renders survey status dropdown when app setup is completed", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
@@ -310,7 +365,7 @@ describe("SurveyAnalysisCTA", () => {
});
test("renders icon bar with correct actions", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
@@ -334,7 +389,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal when share button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -344,7 +399,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal when share param is true", () => {
mockSearchParams.set("share", "true");
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
@@ -352,7 +407,7 @@ describe("SurveyAnalysisCTA", () => {
test("navigates to edit when edit button is clicked and no responses", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
@@ -363,14 +418,15 @@ describe("SurveyAnalysisCTA", () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
// With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button)
await user.click(screen.getByTestId("icon-bar-action-2"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
});
test("navigates to notifications when bell icon is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByTestId("icon-bar-action-0"));
@@ -391,7 +447,7 @@ describe("SurveyAnalysisCTA", () => {
});
test("does not show icon bar actions when read-only", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isReadOnly={true} />);
const iconBar = screen.getByTestId("icon-bar");
expect(iconBar).toBeInTheDocument();
@@ -402,7 +458,7 @@ describe("SurveyAnalysisCTA", () => {
test("handles modal close correctly", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Verify modal is open initially
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
@@ -429,13 +485,13 @@ describe("SurveyAnalysisCTA", () => {
test("does not show status dropdown when app setup is not completed", () => {
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} environment={environmentWithoutAppSetup} />);
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
});
test("renders correctly with all props", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByText("Share survey")).toBeInTheDocument();
@@ -579,8 +635,8 @@ describe("SurveyAnalysisCTA", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
// Click edit button to open dialog (should be icon-bar-action-2 with responses)
await user.click(screen.getByTestId("icon-bar-action-2"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
// Click primary button (duplicate & edit)
@@ -647,7 +703,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal with correct modal view when share button clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -669,24 +725,28 @@ describe("SurveyAnalysisCTA", () => {
});
test("does not render share modal when user is null", () => {
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} user={null as any} />);
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
});
test("renders with different isFormbricksCloud values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
const { rerender } = render(
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={true} />
);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("renders with different isContactsEnabled values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
const { rerender } = render(
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={true} />
);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
@@ -701,7 +761,7 @@ describe("SurveyAnalysisCTA", () => {
test("handles modal state changes correctly", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Open modal via share button
await user.click(screen.getByText("Share survey"));
@@ -714,7 +774,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal via share button", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -726,7 +786,7 @@ describe("SurveyAnalysisCTA", () => {
test("closes share modal and updates modal state", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Modal should be open initially due to share param
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
@@ -738,19 +798,19 @@ describe("SurveyAnalysisCTA", () => {
});
test("handles empty segments array", () => {
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} segments={[]} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("handles zero response count", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} responseCount={0} />);
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
});
test("shows all icon actions for non-readonly app survey", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Should show bell (notifications) and edit actions
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
@@ -766,4 +826,236 @@ describe("SurveyAnalysisCTA", () => {
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
});
// Reset Survey Feature Tests
test("shows reset survey button when responses exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeInTheDocument();
});
test("shows reset survey button when displays exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} displayCount={3} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeInTheDocument();
});
test("hides reset survey button when no responses or displays exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} displayCount={0} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeUndefined();
});
test("hides reset survey button for read-only users", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} responseCount={5} displayCount={3} />);
// For read-only users, there should be no icon bar actions
expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0);
});
test("opens reset confirmation modal when reset button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays");
expect(screen.getByTestId("modal-text")).toHaveTextContent(
"Resetting a survey removes all responses and metadata of this survey. This cannot be undone."
);
});
test("executes reset survey action when confirmed", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(mockResetSurveyAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
organizationId: "test-organization-id",
projectId: "test-project-id",
});
expect(toast.default.success).toHaveBeenCalledWith(
"Survey reset successfully! 5 responses and 3 displays were deleted."
);
});
test("handles reset survey action error", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: undefined,
serverError: "Reset failed",
validationErrors: undefined,
bindArgsValidationErrors: [],
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(toast.default.error).toHaveBeenCalledWith("Error message");
});
test("shows loading state during reset operation", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
// Mock a delayed response
mockResetSurveyAction.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
}),
100
)
)
);
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
// Check loading state
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true");
});
test("closes reset modal after successful reset", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
// Confirm reset - wait for the action to complete
await user.click(screen.getByTestId("confirm-button"));
// Wait for the action to complete and the modal to close
await vi.waitFor(() => {
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
});
});
test("cancels reset operation when cancel button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
// Cancel reset
await user.click(screen.getByTestId("cancel-button"));
// Modal should be closed
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
});
test("shows destructive button variant for reset confirmation", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive");
});
test("refreshes page after successful reset", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(mockRefresh).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
@@ -9,9 +10,10 @@ import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -19,6 +21,7 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -27,6 +30,7 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
displayCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
@@ -44,6 +48,7 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
displayCount,
segments,
isContactsEnabled,
isFormbricksCloud,
@@ -57,7 +62,10 @@ export const SurveyAnalysisCTA = ({
start: searchParams.get("share") === "true",
share: false,
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -118,6 +126,29 @@ export const SurveyAnalysisCTA = ({
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const handleResetSurvey = async () => {
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
toast.success(
t("environments.surveys.summary.survey_reset_successfully", {
responseCount: result.data.deletedResponsesCount,
displayCount: result.data.deletedDisplaysCount,
})
);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsResetting(false);
setIsResetModalOpen(false);
};
const iconActions = [
{
icon: BellRing,
@@ -134,6 +165,12 @@ export const SurveyAnalysisCTA = ({
},
isVisible: survey.type === "link",
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
},
{
icon: SquarePenIcon,
tooltip: t("common.edit"),
@@ -202,6 +239,17 @@ export const SurveyAnalysisCTA = ({
secondaryButtonText={t("common.edit")}
/>
)}
<ConfirmationModal
open={isResetModalOpen}
setOpen={setIsResetModalOpen}
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
text={t("environments.surveys.summary.reset_survey_warning")}
buttonText={t("environments.surveys.summary.reset_survey")}
onConfirm={handleResetSurvey}
buttonVariant="destructive"
buttonLoading={isResetting}
/>
</div>
);
};

View File

@@ -5,6 +5,7 @@ import {
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
@@ -21,10 +22,12 @@ export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: Disabl
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
<DialogHeader className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
<DialogHeader>
<DialogTitle className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
</DialogTitle>
</DialogHeader>
<DialogBody>

View File

@@ -43,6 +43,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
enforceSurveyUrlWidth
/>
</div>
)}

View File

@@ -0,0 +1,83 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
deleteMany: vi.fn(),
},
display: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
});
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
describe("Happy Path", () => {
test("Deletes all responses and displays for a survey", async () => {
const { prisma } = await import("@formbricks/database");
// Mock $transaction to return the results directly
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result).toEqual({
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
});
});
test("Handles case with no responses or displays to delete", async () => {
const { prisma } = await import("@formbricks/database");
// Mock $transaction to return zero counts
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
expect(result).toEqual({
deletedResponsesCount: 0,
deletedDisplaysCount: 0,
});
});
});
describe("Sad Path", () => {
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const { prisma } = await import("@formbricks/database");
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const { prisma } = await import("@formbricks/database");
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
});
});
});

View File

@@ -0,0 +1,36 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export const deleteResponsesAndDisplaysForSurvey = async (
surveyId: string
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
try {
// Delete all responses for this survey
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
prisma.response.deleteMany({
where: {
surveyId: surveyId,
},
}),
prisma.display.deleteMany({
where: {
surveyId: surveyId,
},
}),
]);
return {
deletedResponsesCount: deletedResponsesCount.count,
deletedDisplaysCount: deletedDisplaysCount.count,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -58,6 +58,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

View File

@@ -281,7 +281,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
}`
: t(filterRange)}
: filterRange}
</span>
{isFilterDropDownOpen ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
@@ -296,28 +296,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
setDateRange({ from: undefined, to: getTodayDate() });
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).ALL_TIME)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_7_DAYS)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_30_DAYS)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_MONTH)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -327,14 +327,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfMonth(subMonths(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_MONTH)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_QUARTER)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -344,7 +344,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_QUARTER)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -354,14 +354,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfMonth(getTodayDate()),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_YEAR)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -371,7 +371,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
to: endOfYear(subYears(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_YEAR)}</p>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -380,7 +380,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">
{t(getFilterDropDownLabels(t).CUSTOM_RANGE)}
{getFilterDropDownLabels(t).CUSTOM_RANGE}
</p>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -15,11 +15,13 @@ import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import {
AirplayIcon,
ArrowUpFromDotIcon,
CheckIcon,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
FlagIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -89,8 +91,9 @@ const questionIcons = {
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: GlobeIcon,
source: ArrowUpFromDotIcon,
action: MousePointerClickIcon,
country: FlagIcon,
// others
Language: LanguagesIcon,
@@ -132,10 +135,16 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
return "bg-amber-500";
}
};
const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined;
return label === "os" ? "uppercase" : "capitalize";
};
return (
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
<p className="ml-3 truncate text-sm text-slate-600">
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p>
</div>

View File

@@ -21,8 +21,11 @@ import {
} from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
const defaultButtonLabel = "common.next";
const defaultBackButtonLabel = "common.back";
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFnType) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceQuestion = ({
id,
@@ -63,8 +66,8 @@ export const buildMultipleChoiceQuestion = ({
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
return { id, label: createI18nString(choice, []) };
}),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
shuffleOption: shuffleOption || "none",
required: required ?? false,
logic,
@@ -103,8 +106,8 @@ export const buildOpenTextQuestion = ({
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
longAnswer,
logic,
@@ -151,8 +154,8 @@ export const buildRatingQuestion = ({
headline: createI18nString(headline, []),
scale,
range,
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
@@ -192,8 +195,8 @@ export const buildNPSQuestion = ({
type: TSurveyQuestionTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
@@ -228,8 +231,8 @@ export const buildConsentQuestion = ({
type: TSurveyQuestionTypeEnum.Consent,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
label: createI18nString(label, []),
logic,
@@ -266,8 +269,8 @@ export const buildCTAQuestion = ({
type: TSurveyQuestionTypeEnum.CTA,
html: html ? createI18nString(html, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,

View File

@@ -175,7 +175,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const REDIS_URL = env.REDIS_URL;
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
export const BREVO_API_KEY = env.BREVO_API_KEY;
export const BREVO_LIST_ID = env.BREVO_LIST_ID;

View File

@@ -116,7 +116,7 @@ export const env = createEnv({
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.string().url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
UNKEY_ROOT_KEY: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
@@ -218,7 +218,6 @@ export const env = createEnv({
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY,
NODE_ENV: process.env.NODE_ENV,
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,

View File

@@ -236,7 +236,6 @@
"limits_reached": "Limits erreicht",
"link": "Link",
"link_and_email": "Link & E-Mail",
"link_copied": "Link in die Zwischenablage kopiert!",
"link_survey": "Link-Umfrage",
"link_surveys": "Umfragen verknüpfen",
"load_more": "Mehr laden",
@@ -303,7 +302,6 @@
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"profile": "Profil",
"project": "Projekt",
"project_configuration": "Projektkonfiguration",
"project_id": "Projekt-ID",
"project_name": "Projektname",
@@ -413,7 +411,6 @@
"website_and_app_connection": "Website & App Verbindung",
"website_app_survey": "Website- & App-Umfrage",
"website_survey": "Website-Umfrage",
"weekly_summary": "Wöchentliche Zusammenfassung",
"welcome_card": "Willkommenskarte",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
@@ -454,29 +451,7 @@
"invite_email_text_par1": "Dein Kollege",
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"live_survey_notification_completed": "Abgeschlossen",
"live_survey_notification_draft": "Entwurf",
"live_survey_notification_in_progress": "In Bearbeitung",
"live_survey_notification_no_new_response": "Diese Woche keine neue Antwort erhalten \uD83D\uDD75",
"live_survey_notification_no_responses_yet": "Noch keine Antworten!",
"live_survey_notification_paused": "Pausiert",
"live_survey_notification_scheduled": "Geplant",
"live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten",
"live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen",
"live_survey_notification_view_response": "Antwort anzeigen",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
"notification_footer_all_the_best": "Alles Gute,",
"notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "Bitte ausstellen",
"notification_footer_the_formbricks_team": "Dein Formbricks Team \uD83E\uDD0D",
"notification_footer_to_halt_weekly_updates": "Um wöchentliche Updates zu stoppen,",
"notification_header_hey": "Hey \uD83D\uDC4B",
"notification_header_weekly_report_for": "Wöchentlicher Bericht für",
"notification_insight_completed": "Abgeschlossen",
"notification_insight_completion_rate": "Completion Rate %",
"notification_insight_displays": "Displays",
"notification_insight_responses": "Antworten",
"notification_insight_surveys": "Umfragen",
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
@@ -509,14 +484,7 @@
"verification_email_verify_email": "E-Mail bestätigen",
"verification_new_email_subject": "E-Mail-Änderungsbestätigung",
"verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.",
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",
"weekly_summary_create_reminder_notification_body_need_help": "Brauchst Du Hilfe, die richtige Umfrage für dein Produkt zu finden?",
"weekly_summary_create_reminder_notification_body_reply_email": "oder antworte auf diese E-Mail :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Neue Umfrage einrichten",
"weekly_summary_create_reminder_notification_body_text": "Wir würden dir gerne eine wöchentliche Zusammenfassung schicken, aber momentan laufen keine Umfragen für {projectName}.",
"weekly_summary_email_subject": "{projectName} Nutzer-Insights Letzte Woche von Formbricks"
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen."
},
"environments": {
"actions": {
@@ -1118,10 +1086,8 @@
"need_slack_or_discord_notifications": "Brauchst Du Slack- oder Discord-Benachrichtigungen",
"notification_settings_updated": "Benachrichtigungseinstellungen aktualisiert",
"set_up_an_alert_to_get_an_email_on_new_responses": "Richte eine Benachrichtigung ein, um eine E-Mail bei neuen Antworten zu erhalten",
"stay_up_to_date_with_a_Weekly_every_Monday": "Bleib auf dem Laufenden mit einem wöchentlichen Update jeden Montag",
"use_the_integration": "Integration nutzen",
"want_to_loop_in_organization_mates": "Willst Du die Organisationskollegen einbeziehen?",
"weekly_summary_projects": "Wöchentliche Zusammenfassung (Projekte)",
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Du wirst nicht mehr automatisch zu den Umfragen dieser Organisation angemeldet!",
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Du wirst keine weiteren E-Mails für Antworten auf diese Umfrage erhalten!"
},
@@ -1636,8 +1602,6 @@
"zip": "Postleitzahl"
},
"error_deleting_survey": "Beim Löschen der Umfrage ist ein Fehler aufgetreten",
"failed_to_copy_link_to_results": "Kopieren des Links zu den Ergebnissen fehlgeschlagen",
"failed_to_copy_url": "Kopieren der URL fehlgeschlagen: nicht in einer Browserumgebung.",
"new_survey": "Neue Umfrage",
"no_surveys_created_yet": "Noch keine Umfragen erstellt",
"open_options": "Optionen öffnen",
@@ -1678,7 +1642,6 @@
"this_response_is_in_progress": "Diese Antwort ist in Bearbeitung.",
"zip_post_code": "PLZ / Postleitzahl"
},
"results_unpublished_successfully": "Ergebnisse wurden nicht erfolgreich veröffentlicht.",
"search_by_survey_name": "Nach Umfragenamen suchen",
"share": {
"anonymous_links": {
@@ -1777,8 +1740,8 @@
"configure_alerts": "Benachrichtigungen konfigurieren",
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
"custom_range": "Benutzerdefinierter Bereich...",
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
"download_qr_code": "QR Code herunterladen",
"drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
@@ -1830,40 +1793,33 @@
"last_month": "Letztes Monat",
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
"no_responses_found": "Keine Antworten gefunden",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"publish_to_web": "Im Web veröffentlichen",
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
"qr_code": "QR-Code",
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
"results_are_public": "Ergebnisse sind öffentlich",
"reset_survey": "Umfrage zurücksetzen",
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"setup_integrations": "Integrationen einrichten",
"share_results": "Ergebnisse teilen",
"share_survey": "Umfrage teilen",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
"starts": "Startet",
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
"survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich",
"survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
"this_month": "Dieser Monat",
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung",
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
"unknown_question_type": "Unbekannter Fragetyp",
"unpublish_from_web": "Aus dem Web entfernen",
"use_personal_links": "Nutze persönliche Links",
"view_site": "Seite ansehen",
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8",
"whats_next": "Was kommt als Nächstes?",
"your_survey_is_public": "Deine Umfrage ist öffentlich",
@@ -1994,11 +1950,6 @@
"this_user_has_all_the_power": "Dieser Benutzer hat alle Rechte."
}
},
"share": {
"back_to_home": "Zurück zur Startseite",
"page_not_found": "Seite nicht gefunden",
"page_not_found_description": "Entschuldigung, wir konnten die gesuchten Antworten mit der geteilten ID nicht finden."
},
"templates": {
"address": "Adresse",
"address_description": "Frag nach einer Adresse",

View File

@@ -236,7 +236,6 @@
"limits_reached": "Limits Reached",
"link": "Link",
"link_and_email": "Link & Email",
"link_copied": "Link copied to clipboard!",
"link_survey": "Link Survey",
"link_surveys": "Link Surveys",
"load_more": "Load more",
@@ -303,7 +302,6 @@
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"profile": "Profile",
"project": "Project",
"project_configuration": "Project's Configuration",
"project_id": "Project ID",
"project_name": "Project Name",
@@ -413,7 +411,6 @@
"website_and_app_connection": "Website & App Connection",
"website_app_survey": "Website & App Survey",
"website_survey": "Website Survey",
"weekly_summary": "Weekly summary",
"welcome_card": "Welcome card",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
@@ -454,29 +451,7 @@
"invite_email_text_par1": "Your colleague",
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"live_survey_notification_completed": "Completed",
"live_survey_notification_draft": "Draft",
"live_survey_notification_in_progress": "In Progress",
"live_survey_notification_no_new_response": "No new response received this week \uD83D\uDD75",
"live_survey_notification_no_responses_yet": "No Responses yet!",
"live_survey_notification_paused": "Paused",
"live_survey_notification_scheduled": "Scheduled",
"live_survey_notification_view_more_responses": "View {responseCount} more Responses",
"live_survey_notification_view_previous_responses": "View previous responses",
"live_survey_notification_view_response": "View Response",
"new_email_verification_text": "To verify your new email address, please click the button below:",
"notification_footer_all_the_best": "All the best,",
"notification_footer_in_your_settings": "in your settings \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "please turn them off",
"notification_footer_the_formbricks_team": "The Formbricks Team \uD83E\uDD0D",
"notification_footer_to_halt_weekly_updates": "To halt Weekly Updates,",
"notification_header_hey": "Hey \uD83D\uDC4B",
"notification_header_weekly_report_for": "Weekly Report for",
"notification_insight_completed": "Completed",
"notification_insight_completion_rate": "Completion %",
"notification_insight_displays": "Displays",
"notification_insight_responses": "Responses",
"notification_insight_surveys": "Surveys",
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
@@ -509,14 +484,7 @@
"verification_email_verify_email": "Verify email",
"verification_new_email_subject": "Email change verification",
"verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.",
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",
"weekly_summary_create_reminder_notification_body_need_help": "Need help finding the right survey for your product?",
"weekly_summary_create_reminder_notification_body_reply_email": "or reply to this email :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Setup a new survey",
"weekly_summary_create_reminder_notification_body_text": "We'd love to send you a Weekly Summary, but currently there are no surveys running for {projectName}.",
"weekly_summary_email_subject": "{projectName} User Insights - Last Week by Formbricks"
"verified_link_survey_email_subject": "Your survey is ready to be filled out."
},
"environments": {
"actions": {
@@ -1118,10 +1086,8 @@
"need_slack_or_discord_notifications": "Need Slack or Discord notifications",
"notification_settings_updated": "Notification settings updated",
"set_up_an_alert_to_get_an_email_on_new_responses": "Set up an alert to get an email on new responses",
"stay_up_to_date_with_a_Weekly_every_Monday": "Stay up-to-date with a Weekly every Monday",
"use_the_integration": "Use the integration",
"want_to_loop_in_organization_mates": "Want to loop in organization mates",
"weekly_summary_projects": "Weekly summary (Projects)",
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "You will not be auto-subscribed to this organization's surveys anymore!",
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "You will not receive any more emails for responses on this survey!"
},
@@ -1636,8 +1602,6 @@
"zip": "Zip"
},
"error_deleting_survey": "An error occured while deleting survey",
"failed_to_copy_link_to_results": "Failed to copy link to results",
"failed_to_copy_url": "Failed to copy URL: not in a browser environment.",
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",
@@ -1678,7 +1642,6 @@
"this_response_is_in_progress": "This response is in progress.",
"zip_post_code": "ZIP / Post code"
},
"results_unpublished_successfully": "Results unpublished successfully.",
"search_by_survey_name": "Search by survey name",
"share": {
"anonymous_links": {
@@ -1777,8 +1740,8 @@
"configure_alerts": "Configure alerts",
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"copy_link_to_public_results": "Copy link to public results",
"custom_range": "Custom range...",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
"download_qr_code": "Download QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
@@ -1830,40 +1793,33 @@
"last_month": "Last month",
"last_quarter": "Last quarter",
"last_year": "Last year",
"link_to_public_results_copied": "Link to public results copied",
"no_responses_found": "No responses found",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
"publish_to_web": "Publish to web",
"publish_to_web_warning": "You are about to release these survey results to the public.",
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
"qr_code": "QR code",
"qr_code_description": "Responses collected via QR code are anonymous.",
"qr_code_download_failed": "QR code download failed",
"qr_code_download_with_start_soon": "QR code download will start soon",
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
"results_are_public": "Results are public",
"reset_survey": "Reset survey",
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"setup_integrations": "Setup integrations",
"share_results": "Share results",
"share_survey": "Share survey",
"show_all_responses_that_match": "Show all responses that match",
"show_all_responses_where": "Show all responses where...",
"starts": "Starts",
"starts_tooltip": "Number of times the survey has been started.",
"survey_results_are_public": "Your survey results are public!",
"survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.",
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
"this_month": "This month",
"this_quarter": "This quarter",
"this_year": "This year",
"time_to_complete": "Time to Complete",
"ttc_tooltip": "Average time to complete the survey.",
"unknown_question_type": "Unknown Question Type",
"unpublish_from_web": "Unpublish from web",
"use_personal_links": "Use personal links",
"view_site": "View site",
"waiting_for_response": "Waiting for a response \uD83E\uDDD8",
"whats_next": "What's next?",
"your_survey_is_public": "Your survey is public",
@@ -1994,11 +1950,6 @@
"this_user_has_all_the_power": "This user has all the power."
}
},
"share": {
"back_to_home": "Back to home",
"page_not_found": "Page not found",
"page_not_found_description": "Sorry, we couldn't find the responses sharing ID you're looking for."
},
"templates": {
"address": "Address",
"address_description": "Ask for a mailing address",

View File

@@ -236,7 +236,6 @@
"limits_reached": "Limites atteints",
"link": "Lien",
"link_and_email": "Liens et e-mail",
"link_copied": " lien copié dans le presse-papiers !",
"link_survey": "Enquête de lien",
"link_surveys": "Sondages de lien",
"load_more": "Charger plus",
@@ -303,7 +302,6 @@
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"profile": "Profil",
"project": "Projet",
"project_configuration": "Configuration du projet",
"project_id": "ID de projet",
"project_name": "Nom du projet",
@@ -413,7 +411,6 @@
"website_and_app_connection": "Connexion Site Web & Application",
"website_app_survey": "Sondage sur le site Web et l'application",
"website_survey": "Sondage de site web",
"weekly_summary": "Résumé hebdomadaire",
"welcome_card": "Carte de bienvenue",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
@@ -454,29 +451,7 @@
"invite_email_text_par1": "Votre collègue",
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"live_survey_notification_completed": "Terminé",
"live_survey_notification_draft": "Brouillon",
"live_survey_notification_in_progress": "En cours",
"live_survey_notification_no_new_response": "Aucune nouvelle réponse reçue cette semaine \uD83D\uDD75",
"live_survey_notification_no_responses_yet": "Aucune réponse pour le moment !",
"live_survey_notification_paused": "En pause",
"live_survey_notification_scheduled": "Programmé",
"live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires",
"live_survey_notification_view_previous_responses": "Voir les réponses précédentes",
"live_survey_notification_view_response": "Voir la réponse",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
"notification_footer_all_the_best": "Tous mes vœux,",
"notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "veuillez les éteindre",
"notification_footer_the_formbricks_team": "L'équipe Formbricks \uD83E\uDD0D",
"notification_footer_to_halt_weekly_updates": "Pour arrêter les mises à jour hebdomadaires,",
"notification_header_hey": "Salut \uD83D\uDC4B",
"notification_header_weekly_report_for": "Rapport hebdomadaire pour",
"notification_insight_completed": "Terminé",
"notification_insight_completion_rate": "Pourcentage d'achèvement",
"notification_insight_displays": "Affichages",
"notification_insight_responses": "Réponses",
"notification_insight_surveys": "Enquêtes",
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
@@ -509,14 +484,7 @@
"verification_email_verify_email": "Vérifier l'email",
"verification_new_email_subject": "Vérification du changement d'email",
"verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.",
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",
"weekly_summary_create_reminder_notification_body_need_help": "Besoin d'aide pour trouver le bon sondage pour votre produit ?",
"weekly_summary_create_reminder_notification_body_reply_email": "ou répondez à cet e-mail :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurer une nouvelle enquête",
"weekly_summary_create_reminder_notification_body_text": "Nous aimerions vous envoyer un résumé hebdomadaire, mais actuellement, il n'y a pas d'enquêtes en cours pour {projectName}.",
"weekly_summary_email_subject": "Aperçu des utilisateurs de {projectName} La semaine dernière par Formbricks"
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie."
},
"environments": {
"actions": {
@@ -1118,10 +1086,8 @@
"need_slack_or_discord_notifications": "Besoin de notifications Slack ou Discord",
"notification_settings_updated": "Paramètres de notification mis à jour",
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurez une alerte pour recevoir un e-mail lors de nouvelles réponses.",
"stay_up_to_date_with_a_Weekly_every_Monday": "Restez à jour avec un hebdomadaire chaque lundi.",
"use_the_integration": "Utilisez l'intégration",
"want_to_loop_in_organization_mates": "Voulez-vous inclure des collègues de l'organisation ?",
"weekly_summary_projects": "Résumé hebdomadaire (Projets)",
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Vous ne serez plus automatiquement abonné aux enquêtes de cette organisation !",
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Vous ne recevrez plus d'e-mails concernant les réponses à cette enquête !"
},
@@ -1636,8 +1602,6 @@
"zip": "Zip"
},
"error_deleting_survey": "Une erreur est survenue lors de la suppression de l'enquête.",
"failed_to_copy_link_to_results": "Échec de la copie du lien vers les résultats",
"failed_to_copy_url": "Échec de la copie de l'URL : pas dans un environnement de navigateur.",
"new_survey": "Nouveau Sondage",
"no_surveys_created_yet": "Aucun sondage créé pour le moment",
"open_options": "Ouvrir les options",
@@ -1678,7 +1642,6 @@
"this_response_is_in_progress": "Cette réponse est en cours.",
"zip_post_code": "Code postal"
},
"results_unpublished_successfully": "Résultats publiés avec succès.",
"search_by_survey_name": "Recherche par nom d'enquête",
"share": {
"anonymous_links": {
@@ -1777,8 +1740,8 @@
"configure_alerts": "Configurer les alertes",
"congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
"custom_range": "Plage personnalisée...",
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
"download_qr_code": "Télécharger code QR",
"drop_offs": "Dépôts",
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
@@ -1830,40 +1793,33 @@
"last_month": "Le mois dernier",
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"link_to_public_results_copied": "Lien vers les résultats publics copié",
"no_responses_found": "Aucune réponse trouvée",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"publish_to_web": "Publier sur le web",
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
"qr_code": "Code QR",
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
"qr_code_download_failed": "Échec du téléchargement du code QR",
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
"results_are_public": "Les résultats sont publics.",
"reset_survey": "Réinitialiser l'enquête",
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
"setup_integrations": "Configurer les intégrations",
"share_results": "Partager les résultats",
"share_survey": "Partager l'enquête",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
"show_all_responses_where": "Afficher toutes les réponses où...",
"starts": "Commence",
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
"survey_results_are_public": "Les résultats de votre enquête sont publics !",
"survey_results_are_shared_with_anyone_who_has_the_link": "Les résultats de votre enquête sont partagés avec quiconque possède le lien. Les résultats ne seront pas indexés par les moteurs de recherche.",
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
"this_month": "Ce mois-ci",
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
"time_to_complete": "Temps à compléter",
"ttc_tooltip": "Temps moyen pour compléter l'enquête.",
"unknown_question_type": "Type de question inconnu",
"unpublish_from_web": "Désactiver la publication sur le web",
"use_personal_links": "Utilisez des liens personnels",
"view_site": "Voir le site",
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8",
"whats_next": "Qu'est-ce qui vient ensuite ?",
"your_survey_is_public": "Votre enquête est publique.",
@@ -1994,11 +1950,6 @@
"this_user_has_all_the_power": "Cet utilisateur a tout le pouvoir."
}
},
"share": {
"back_to_home": "Retour à l'accueil",
"page_not_found": "Page non trouvée",
"page_not_found_description": "Désolé, nous n'avons pas pu trouver l'ID de partage des réponses que vous recherchez."
},
"templates": {
"address": "Adresse",
"address_description": "Demander une adresse postale",

View File

@@ -236,7 +236,6 @@
"limits_reached": "Limites Atingidos",
"link": "link",
"link_and_email": "Link & E-mail",
"link_copied": "Link copiado para a área de transferência!",
"link_survey": "Pesquisa de Link",
"link_surveys": "Link de Pesquisas",
"load_more": "Carregar mais",
@@ -303,7 +302,6 @@
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"project": "Projeto",
"project_configuration": "Configuração do Projeto",
"project_id": "ID do Projeto",
"project_name": "Nome do Projeto",
@@ -413,7 +411,6 @@
"website_and_app_connection": "Conexão de Site e App",
"website_app_survey": "Pesquisa de Site e App",
"website_survey": "Pesquisa de Site",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
@@ -454,29 +451,7 @@
"invite_email_text_par1": "Seu colega",
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"live_survey_notification_completed": "Concluído",
"live_survey_notification_draft": "Rascunho",
"live_survey_notification_in_progress": "Em andamento",
"live_survey_notification_no_new_response": "Nenhuma resposta nova recebida essa semana \uD83D\uDD75",
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
"live_survey_notification_paused": "Pausado",
"live_survey_notification_scheduled": "agendado",
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
"live_survey_notification_view_response": "Ver Resposta",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
"notification_footer_all_the_best": "Tudo de bom,",
"notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "por favor, desliga eles",
"notification_footer_the_formbricks_team": "A Equipe Formbricks \uD83E\uDD0D",
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
"notification_header_hey": "Oi \uD83D\uDC4B",
"notification_header_weekly_report_for": "Relatório Semanal de",
"notification_insight_completed": "Concluído",
"notification_insight_completion_rate": "Conclusão %",
"notification_insight_displays": "telas",
"notification_insight_responses": "Respostas",
"notification_insight_surveys": "pesquisas",
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
@@ -509,14 +484,7 @@
"verification_email_verify_email": "Verificar e-mail",
"verification_new_email_subject": "Verificação de alteração de e-mail",
"verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.",
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda pra encontrar a pesquisa certa pro seu produto?",
"weekly_summary_create_reminder_notification_body_reply_email": "ou responde a esse e-mail :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar uma nova pesquisa",
"weekly_summary_create_reminder_notification_body_text": "Adoraríamos te enviar um Resumo Semanal, mas no momento não há pesquisas em andamento para {projectName}.",
"weekly_summary_email_subject": "Insights de usuários do {projectName} Semana passada por Formbricks"
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida."
},
"environments": {
"actions": {
@@ -1118,10 +1086,8 @@
"need_slack_or_discord_notifications": "Preciso de notificações no Slack ou Discord",
"notification_settings_updated": "Configurações de notificação atualizadas",
"set_up_an_alert_to_get_an_email_on_new_responses": "Configura um alerta pra receber um e-mail com novas respostas",
"stay_up_to_date_with_a_Weekly_every_Monday": "Fique por dentro com um resumo semanal toda segunda-feira",
"use_the_integration": "Use a integração",
"want_to_loop_in_organization_mates": "Quero incluir os colegas da organização",
"weekly_summary_projects": "Resumo semanal (Projetos)",
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Você não vai ser mais inscrito automaticamente nas pesquisas dessa organização!",
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Você não vai receber mais e-mails sobre respostas dessa pesquisa!"
},
@@ -1636,8 +1602,6 @@
"zip": "Fecho éclair"
},
"error_deleting_survey": "Ocorreu um erro ao deletar a pesquisa",
"failed_to_copy_link_to_results": "Falha ao copiar link dos resultados",
"failed_to_copy_url": "Falha ao copiar URL: não está em um ambiente de navegador.",
"new_survey": "Nova Pesquisa",
"no_surveys_created_yet": "Ainda não foram criadas pesquisas",
"open_options": "Abre opções",
@@ -1678,7 +1642,6 @@
"this_response_is_in_progress": "Essa resposta está em andamento.",
"zip_post_code": "CEP / Código postal"
},
"results_unpublished_successfully": "Resultados não publicados com sucesso.",
"search_by_survey_name": "Buscar pelo nome da pesquisa",
"share": {
"anonymous_links": {
@@ -1777,8 +1740,8 @@
"configure_alerts": "Configurar alertas",
"congrats": "Parabéns! Sua pesquisa está no ar.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
"download_qr_code": "baixar código QR",
"drop_offs": "Pontos de Entrega",
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
@@ -1830,40 +1793,33 @@
"last_month": "Último mês",
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"link_to_public_results_copied": "Link pros resultados públicos copiado",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"publish_to_web": "Publicar na web",
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
"qr_code": "Código QR",
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
"qr_code_download_failed": "falha no download do código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
"results_are_public": "Os resultados são públicos",
"reset_survey": "Redefinir pesquisa",
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"setup_integrations": "Configurar integrações",
"share_results": "Compartilhar resultados",
"share_survey": "Compartilhar pesquisa",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
"show_all_responses_where": "Mostre todas as respostas onde...",
"starts": "começa",
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
"survey_results_are_public": "Os resultados da sua pesquisa são públicos!",
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.",
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
"this_month": "Este mês",
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_tooltip": "Tempo médio para completar a pesquisa.",
"unknown_question_type": "Tipo de pergunta desconhecido",
"unpublish_from_web": "Despublicar da web",
"use_personal_links": "Use links pessoais",
"view_site": "Ver site",
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8",
"whats_next": "E agora?",
"your_survey_is_public": "Sua pesquisa é pública",
@@ -1994,11 +1950,6 @@
"this_user_has_all_the_power": "Esse usuário tem todo o poder."
}
},
"share": {
"back_to_home": "Voltar pra casa",
"page_not_found": "Página não encontrada",
"page_not_found_description": "Desculpa, não conseguimos encontrar as respostas com o ID que você está procurando."
},
"templates": {
"address": "endereço",
"address_description": "Pede um endereço pra correspondência",

View File

@@ -236,7 +236,6 @@
"limits_reached": "Limites Atingidos",
"link": "Link",
"link_and_email": "Link e Email",
"link_copied": "Link copiado para a área de transferência!",
"link_survey": "Ligar Inquérito",
"link_surveys": "Ligar Inquéritos",
"load_more": "Carregar mais",
@@ -303,7 +302,6 @@
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"project": "Projeto",
"project_configuration": "Configuração do Projeto",
"project_id": "ID do Projeto",
"project_name": "Nome do Projeto",
@@ -413,7 +411,6 @@
"website_and_app_connection": "Ligação de Website e Aplicação",
"website_app_survey": "Inquérito do Website e da Aplicação",
"website_survey": "Inquérito do Website",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
@@ -454,29 +451,7 @@
"invite_email_text_par1": "O seu colega",
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"live_survey_notification_completed": "Concluído",
"live_survey_notification_draft": "Rascunho",
"live_survey_notification_in_progress": "Em Progresso",
"live_survey_notification_no_new_response": "Nenhuma nova resposta recebida esta semana \uD83D\uDD75",
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
"live_survey_notification_paused": "Pausado",
"live_survey_notification_scheduled": "Agendado",
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
"live_survey_notification_view_response": "Ver Resposta",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
"notification_footer_all_the_best": "Tudo de bom,",
"notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "por favor, desative-os",
"notification_footer_the_formbricks_team": "A Equipa Formbricks \uD83E\uDD0D",
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
"notification_header_hey": "Olá \uD83D\uDC4B",
"notification_header_weekly_report_for": "Relatório Semanal para",
"notification_insight_completed": "Concluído",
"notification_insight_completion_rate": "Conclusão %",
"notification_insight_displays": "Ecrãs",
"notification_insight_responses": "Respostas",
"notification_insight_surveys": "Inquéritos",
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
@@ -509,14 +484,7 @@
"verification_email_verify_email": "Verificar email",
"verification_new_email_subject": "Verificação de alteração de email",
"verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.",
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:",
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda para encontrar o inquérito certo para o seu produto?",
"weekly_summary_create_reminder_notification_body_reply_email": "ou responda a este email :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar um novo inquérito",
"weekly_summary_create_reminder_notification_body_text": "Gostaríamos de lhe enviar um Resumo Semanal, mas de momento não há inquéritos a decorrer para {projectName}.",
"weekly_summary_email_subject": "{projectName} Informações do Utilizador - Última Semana por Formbricks"
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido."
},
"environments": {
"actions": {
@@ -1118,10 +1086,8 @@
"need_slack_or_discord_notifications": "Precisa de notificações do Slack ou Discord",
"notification_settings_updated": "Definições de notificações atualizadas",
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurar um alerta para receber um e-mail sobre novas respostas",
"stay_up_to_date_with_a_Weekly_every_Monday": "Mantenha-se atualizado com um Resumo semanal todas as segundas-feiras",
"use_the_integration": "Use a integração",
"want_to_loop_in_organization_mates": "Quer incluir colegas da organização",
"weekly_summary_projects": "Resumo semanal (Projetos)",
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Já não será automaticamente subscrito aos inquéritos desta organização!",
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Não receberá mais emails para respostas a este inquérito!"
},
@@ -1636,8 +1602,6 @@
"zip": "Comprimir"
},
"error_deleting_survey": "Ocorreu um erro ao eliminar o questionário",
"failed_to_copy_link_to_results": "Falha ao copiar link para resultados",
"failed_to_copy_url": "Falha ao copiar URL: não está num ambiente de navegador.",
"new_survey": "Novo inquérito",
"no_surveys_created_yet": "Ainda não foram criados questionários",
"open_options": "Abrir opções",
@@ -1678,7 +1642,6 @@
"this_response_is_in_progress": "Esta resposta está em progresso.",
"zip_post_code": "Código Postal"
},
"results_unpublished_successfully": "Resultados despublicados com sucesso.",
"search_by_survey_name": "Pesquisar por nome do inquérito",
"share": {
"anonymous_links": {
@@ -1777,8 +1740,8 @@
"configure_alerts": "Configurar alertas",
"congrats": "Parabéns! O seu inquérito está ativo.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
"download_qr_code": "Transferir código QR",
"drop_offs": "Desistências",
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
@@ -1830,40 +1793,33 @@
"last_month": "Último mês",
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"link_to_public_results_copied": "Link para resultados públicos copiado",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"publish_to_web": "Publicar na web",
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
"qr_code": "Código QR",
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
"qr_code_download_failed": "Falha ao transferir o código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
"results_are_public": "Os resultados são públicos",
"reset_survey": "Reiniciar inquérito",
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"setup_integrations": "Configurar integrações",
"share_results": "Partilhar resultados",
"share_survey": "Partilhar inquérito",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
"show_all_responses_where": "Mostrar todas as respostas onde...",
"starts": "Começa",
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
"survey_results_are_public": "Os resultados do seu inquérito são públicos!",
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados do seu inquérito são partilhados com qualquer pessoa que tenha o link. Os resultados não serão indexados pelos motores de busca.",
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
"this_month": "Este mês",
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_tooltip": "Tempo médio para concluir o inquérito.",
"unknown_question_type": "Tipo de Pergunta Desconhecido",
"unpublish_from_web": "Despublicar da web",
"use_personal_links": "Utilize links pessoais",
"view_site": "Ver site",
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8",
"whats_next": "O que se segue?",
"your_survey_is_public": "O seu inquérito é público",
@@ -1994,11 +1950,6 @@
"this_user_has_all_the_power": "Este utilizador tem todo o poder."
}
},
"share": {
"back_to_home": "Voltar para casa",
"page_not_found": "Página não encontrada",
"page_not_found_description": "Desculpe, não conseguimos encontrar o ID de partilha de respostas que está a procurar."
},
"templates": {
"address": "Endereço",
"address_description": "Pedir um endereço de correspondência",

View File

@@ -236,7 +236,6 @@
"limits_reached": "已達上限",
"link": "連結",
"link_and_email": "連結與電子郵件",
"link_copied": "連結已複製到剪貼簿!",
"link_survey": "連結問卷",
"link_surveys": "連結問卷",
"load_more": "載入更多",
@@ -303,7 +302,6 @@
"privacy": "隱私權政策",
"product_manager": "產品經理",
"profile": "個人資料",
"project": "專案",
"project_configuration": "專案組態",
"project_id": "專案 ID",
"project_name": "專案名稱",
@@ -413,7 +411,6 @@
"website_and_app_connection": "網站與應用程式連線",
"website_app_survey": "網站與應用程式問卷",
"website_survey": "網站問卷",
"weekly_summary": "每週摘要",
"welcome_card": "歡迎卡片",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
@@ -454,29 +451,7 @@
"invite_email_text_par1": "您的同事",
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請請點擊以下連結",
"invite_member_email_subject": "您被邀請協作 Formbricks",
"live_survey_notification_completed": "已完成",
"live_survey_notification_draft": "草稿",
"live_survey_notification_in_progress": "進行中",
"live_survey_notification_no_new_response": "本週沒有收到新的回應 \uD83D\uDD75",
"live_survey_notification_no_responses_yet": "尚無回應!",
"live_survey_notification_paused": "已暫停",
"live_survey_notification_scheduled": "已排程",
"live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
"live_survey_notification_view_previous_responses": "檢視先前的回應",
"live_survey_notification_view_response": "檢視回應",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
"notification_footer_all_the_best": "祝您一切順利,",
"notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "請關閉它們",
"notification_footer_the_formbricks_team": "Formbricks 團隊 \uD83E\uDD0D",
"notification_footer_to_halt_weekly_updates": "若要停止每週更新,",
"notification_header_hey": "嗨 \uD83D\uDC4B",
"notification_header_weekly_report_for": "每週報告,適用於",
"notification_insight_completed": "已完成",
"notification_insight_completion_rate": "完成率 %",
"notification_insight_displays": "顯示次數",
"notification_insight_responses": "回應數",
"notification_insight_surveys": "問卷數",
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
@@ -509,14 +484,7 @@
"verification_email_verify_email": "驗證電子郵件",
"verification_new_email_subject": "電子郵件更改驗證",
"verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。",
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。",
"weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:",
"weekly_summary_create_reminder_notification_body_need_help": "需要協助找到適合您產品的問卷嗎?",
"weekly_summary_create_reminder_notification_body_reply_email": "或回覆此電子郵件 :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "設定新的問卷",
"weekly_summary_create_reminder_notification_body_text": "我們很樂意向您發送每週摘要,但目前 '{'projectName'}' 沒有正在執行的問卷。",
"weekly_summary_email_subject": "{projectName} 用戶洞察 - 上週 by Formbricks"
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。"
},
"environments": {
"actions": {
@@ -1118,10 +1086,8 @@
"need_slack_or_discord_notifications": "需要 Slack 或 Discord 通知嗎?",
"notification_settings_updated": "通知設定已更新",
"set_up_an_alert_to_get_an_email_on_new_responses": "設定警示以在收到新回應時收到電子郵件",
"stay_up_to_date_with_a_Weekly_every_Monday": "每週一使用每週摘要保持最新資訊",
"use_the_integration": "使用整合",
"want_to_loop_in_organization_mates": "想要讓組織夥伴也參與嗎?",
"weekly_summary_projects": "每週摘要(專案)",
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "您將不會再自動訂閱此組織的問卷!",
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "您將不會再收到此問卷回應的電子郵件!"
},
@@ -1636,8 +1602,6 @@
"zip": "郵遞區號"
},
"error_deleting_survey": "刪除問卷時發生錯誤",
"failed_to_copy_link_to_results": "無法複製結果連結",
"failed_to_copy_url": "無法複製網址:不在瀏覽器環境中。",
"new_survey": "新增問卷",
"no_surveys_created_yet": "尚未建立任何問卷",
"open_options": "開啟選項",
@@ -1678,7 +1642,6 @@
"this_response_is_in_progress": "此回應正在進行中。",
"zip_post_code": "郵遞區號"
},
"results_unpublished_successfully": "結果已成功取消發布。",
"search_by_survey_name": "依問卷名稱搜尋",
"share": {
"anonymous_links": {
@@ -1777,8 +1740,8 @@
"configure_alerts": "設定警示",
"congrats": "恭喜!您的問卷已上線。",
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"copy_link_to_public_results": "複製公開結果的連結",
"custom_range": "自訂範圍...",
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
"download_qr_code": "下載 QR code",
"drop_offs": "放棄",
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
@@ -1830,40 +1793,33 @@
"last_month": "上個月",
"last_quarter": "上一季",
"last_year": "去年",
"link_to_public_results_copied": "已複製公開結果的連結",
"no_responses_found": "找不到回應",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
"publish_to_web": "發布至網站",
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
"qr_code": "QR 碼",
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
"qr_code_download_failed": "QR code 下載失敗",
"qr_code_download_with_start_soon": "QR code 下載即將開始",
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
"results_are_public": "結果是公開的",
"reset_survey": "重設問卷",
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
"setup_integrations": "設定整合",
"share_results": "分享結果",
"share_survey": "分享問卷",
"show_all_responses_that_match": "顯示所有相符的回應",
"show_all_responses_where": "顯示所有回應,其中...",
"starts": "開始次數",
"starts_tooltip": "問卷已開始的次數。",
"survey_results_are_public": "您的問卷結果是公開的!",
"survey_results_are_shared_with_anyone_who_has_the_link": "您的問卷結果與任何擁有連結的人員分享。這些結果將不會被搜尋引擎編入索引。",
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
"this_month": "本月",
"this_quarter": "本季",
"this_year": "今年",
"time_to_complete": "完成時間",
"ttc_tooltip": "完成問卷的平均時間。",
"unknown_question_type": "未知的問題類型",
"unpublish_from_web": "從網站取消發布",
"use_personal_links": "使用 個人 連結",
"view_site": "檢視網站",
"waiting_for_response": "正在等待回應 \uD83E\uDDD8",
"whats_next": "下一步是什麼?",
"your_survey_is_public": "您的問卷是公開的",
@@ -1994,11 +1950,6 @@
"this_user_has_all_the_power": "此使用者擁有所有權限。"
}
},
"share": {
"back_to_home": "返回首頁",
"page_not_found": "找不到頁面",
"page_not_found_description": "抱歉,我們找不到您要尋找的回應分享 ID。"
},
"templates": {
"address": "地址",
"address_description": "要求郵寄地址",

View File

@@ -1,25 +1,37 @@
import { cn } from "@/lib/cn";
import { Input } from "@/modules/ui/components/input";
interface SurveyLinkDisplayProps {
surveyUrl: string;
enforceSurveyUrlWidth?: boolean;
}
export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
export const SurveyLinkDisplay = ({ surveyUrl, enforceSurveyUrlWidth = false }: SurveyLinkDisplayProps) => {
return (
<>
{surveyUrl ? (
<Input
data-testid="survey-url-input"
autoFocus={true}
className="h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
className={cn(
"h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent",
{
"min-w-96": enforceSurveyUrlWidth,
}
)}
value={surveyUrl}
readOnly
aria-label="Survey URL"
/>
) : (
//loading state
<div
data-testid="loading-div"
className="h-9 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent"
className={cn(
"h-9 w-full animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent",
{
"min-w-96": enforceSurveyUrlWidth,
}
)}
/>
)}
</>

View File

@@ -17,6 +17,7 @@ interface ShareSurveyLinkProps {
surveyUrl: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
enforceSurveyUrlWidth?: boolean;
}
export const ShareSurveyLink = ({
@@ -25,6 +26,7 @@ export const ShareSurveyLink = ({
publicDomain,
setSurveyUrl,
locale,
enforceSurveyUrlWidth = false,
}: ShareSurveyLinkProps) => {
const { t } = useTranslate();
@@ -50,8 +52,12 @@ export const ShareSurveyLink = ({
};
return (
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
<div className={"flex max-w-full items-center justify-center gap-2"}>
<SurveyLinkDisplay
surveyUrl={surveyUrl}
key={surveyUrl}
enforceSurveyUrlWidth={enforceSurveyUrlWidth}
/>
<div className="flex items-center justify-center space-x-2">
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
<Button

View File

@@ -1,6 +1,7 @@
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { ZodRawShape, z } from "zod";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "./authenticate-request";
@@ -104,11 +105,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
}
if (rateLimit) {
const rateLimitResponse = await checkRateLimitAndThrowError({
identifier: authentication.data.hashedApiKey,
});
if (!rateLimitResponse.ok) {
return handleApiError(request, rateLimitResponse.error);
try {
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
} catch (error) {
return handleApiError(request, { type: "too_many_requests", details: error.message });
}
}

View File

@@ -1,22 +1,26 @@
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
import { err, ok } from "@formbricks/types/error-handlers";
vi.mock("../authenticate-request", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
checkRateLimitAndThrowError: vi.fn(),
vi.mock("@/modules/core/rate-limit/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" },
},
},
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
@@ -24,20 +28,31 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
}));
const mockAuthentication = {
type: "apiKey" as const,
environmentPermissions: [
{
environmentId: "env-id",
environmentType: "development" as const,
projectId: "project-id",
projectName: "Project Name",
permission: "manage" as const,
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {} as any,
} as any;
describe("apiWrapper", () => {
test("should handle request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
@@ -74,13 +89,7 @@ describe("apiWrapper", () => {
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -107,14 +116,7 @@ describe("apiWrapper", () => {
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
@@ -134,13 +136,7 @@ describe("apiWrapper", () => {
test("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const querySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -163,14 +159,7 @@ describe("apiWrapper", () => {
test("should handle query schema errors", async () => {
const request = new Request("http://localhost?foo%ZZ=abc");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const querySchema = z.object({ key: z.string() });
@@ -190,13 +179,7 @@ describe("apiWrapper", () => {
test("should parse params schema correctly", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -220,14 +203,7 @@ describe("apiWrapper", () => {
test("should handle no external params", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
@@ -248,14 +224,7 @@ describe("apiWrapper", () => {
test("should handle params schema errors", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
@@ -273,21 +242,13 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("should handle rate limit errors", async () => {
test("should handle rate limit exceeded", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: false }));
vi.mocked(handleApiError).mockImplementation(
(_request: Request, _error: ApiErrorResponseV2): Response =>
new Response("rate limit exceeded", { status: 429 })
@@ -302,4 +263,24 @@ describe("apiWrapper", () => {
expect(response.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
test("should handle rate limit check failure gracefully", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
// When rate limiting fails (e.g., Redis connection issues), checkRateLimit fails open by returning allowed: true
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
handler,
});
// Should fail open for availability
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
});
});

View File

@@ -1,71 +0,0 @@
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
import { logger } from "@formbricks/logger";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export type RateLimitHelper = {
identifier: string;
opts?: LimitOptions;
/**
* Using a callback instead of a regular return to provide headers even
* when the rate limit is reached and an error is thrown.
**/
onRateLimiterResponse?: (response: RatelimitResponse) => void;
};
let warningDisplayed = false;
/** Prevent flooding the logs while testing/building */
function logOnce(message: string) {
if (warningDisplayed) return;
logger.warn(message);
warningDisplayed = true;
}
export function rateLimiter() {
if (RATE_LIMITING_DISABLED) {
logOnce("Rate limiting disabled");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
if (!UNKEY_ROOT_KEY) {
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
const timeout = {
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
ms: 5000,
};
const limiter = {
api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
namespace: "api",
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
timeout,
}),
};
async function rateLimit({ identifier, opts }: RateLimitHelper) {
return await limiter.api.limit(identifier, opts);
}
return rateLimit;
}
export const checkRateLimitAndThrowError = async ({
identifier,
opts,
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
const response = await rateLimiter()({ identifier, opts });
const { success } = response;
if (!success) {
return err({
type: "too_many_requests",
});
}
return okVoid();
};

View File

@@ -1,114 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
vi.mock("@formbricks/logger", () => ({
logger: {
warn: vi.fn(),
},
}));
vi.mock("@unkey/ratelimit", () => ({
Ratelimit: vi.fn(),
}));
describe("when rate limiting is disabled", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: true,
}));
});
test("should log a warning once and return a stubbed response", async () => {
const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const res1 = await rateLimiter()({ identifier: "test-id" });
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(loggerSpy).toHaveBeenCalled();
// Subsequent calls won't log again.
await rateLimiter()({ identifier: "another-id" });
expect(loggerSpy).toHaveBeenCalledTimes(1);
loggerSpy.mockRestore();
});
});
describe("when UNKEY_ROOT_KEY is missing", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "",
}));
});
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "test-id" });
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(loggerSpy).toHaveBeenCalled();
loggerSpy.mockRestore();
});
});
describe("when rate limiting is active (enabled)", () => {
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
let limitMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "valid-key",
}));
limitMock = vi.fn().mockResolvedValue(mockResponse);
const RatelimitMock = vi.fn().mockImplementation(() => {
return { limit: limitMock };
});
vi.doMock("@unkey/ratelimit", () => ({
Ratelimit: RatelimitMock,
}));
});
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
const { rateLimiter } = await import("../rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
expect(res).toEqual(mockResponse);
});
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(true);
});
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "too_many_requests" });
}
});
});

View File

@@ -92,7 +92,7 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
});
}
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);

View File

@@ -82,11 +82,11 @@ export const GET = async (
}
// Generate survey links for each contact
const contactLinks = contacts
.map((contact) => {
const contactLinks = await Promise.all(
contacts.map(async (contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(
const surveyUrlResult = await getContactSurveyLink(
contactId,
params.surveyId,
query?.expirationDays || undefined
@@ -107,10 +107,11 @@ export const GET = async (
expiresAt,
};
})
.filter(Boolean);
);
const filteredContactLinks = contactLinks.filter(Boolean);
return responses.successResponse({
data: contactLinks,
data: filteredContactLinks,
meta,
});
},

View File

@@ -72,7 +72,7 @@ describe("rateLimitConfigs", () => {
test("should have all action configurations", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual(["profileUpdate", "surveyFollowUp"]);
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]);
});
test("should have all share configurations", () => {
@@ -137,7 +137,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
{ config: rateLimitConfigs.actions.profileUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.share.url, identifier: "share-url" },
];

View File

@@ -16,7 +16,7 @@ export const rateLimitConfigs = {
// Server actions - varies by action type
actions: {
profileUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:profile" }, // 3 per hour
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
},

View File

@@ -71,7 +71,7 @@ export const PricingCard = ({
window.open(plan.href, "_blank", "noopener,noreferrer");
}}
className="flex justify-center bg-white">
{t(plan.CTA ?? "common.request_pricing")}
{plan.CTA ?? t("common.request_pricing")}
</Button>
);
}
@@ -88,7 +88,7 @@ export const PricingCard = ({
setLoading(false);
}}
className="flex justify-center">
{t(plan.CTA ?? "common.start_free_trial")}
{plan.CTA ?? t("common.start_free_trial")}
</Button>
);
}
@@ -138,7 +138,7 @@ export const PricingCard = ({
plan.featured ? "text-slate-900" : "text-slate-800",
"text-sm font-semibold leading-6"
)}>
{t(plan.name)}
{plan.name}
</h2>
{isCurrentPlan && (
<Badge type="success" size="normal" text={t("environments.settings.billing.current_plan")} />
@@ -155,7 +155,7 @@ export const PricingCard = ({
? planPeriod === "monthly"
? plan.price.monthly
: plan.price.yearly
: t(plan.price.monthly)}
: plan.price.monthly}
</p>
{plan.id !== projectFeatureKeys.ENTERPRISE && (
<div className="text-sm leading-5">
@@ -196,7 +196,7 @@ export const PricingCard = ({
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
aria-hidden="true"
/>
{t(mainFeature)}
{mainFeature}
</li>
))}
</ul>
@@ -215,7 +215,7 @@ export const PricingCard = ({
open={upgradeModalOpen}
setOpen={setUpgradeModalOpen}
text={t("environments.settings.billing.switch_plan_confirmation_text", {
plan: t(plan.name),
plan: plan.name,
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
period:
planPeriod === "monthly"

View File

@@ -1,8 +1,11 @@
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 { getSurvey } from "@/modules/survey/lib/survey";
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import * as contactSurveyLink from "./contact-survey-link";
// Mock all modules needed (this gets hoisted to the top of the file)
@@ -33,12 +36,22 @@ vi.mock("@/lib/crypto", () => ({
symmetricDecrypt: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/lib/utils/single-use-surveys", () => ({
generateSurveySingleUseId: vi.fn(),
}));
describe("Contact Survey Link", () => {
const mockContactId = "contact-123";
const mockSurveyId = "survey-456";
const mockToken = "mock.jwt.token";
const mockEncryptedContactId = "encrypted-contact-id";
const mockEncryptedSurveyId = "encrypted-survey-id";
const mockedGetSurvey = vi.mocked(getSurvey);
const mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId);
beforeEach(() => {
vi.clearAllMocks();
@@ -60,11 +73,17 @@ describe("Contact Survey Link", () => {
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
} as any);
mockedGetSurvey.mockResolvedValue({
id: mockSurveyId,
singleUse: { enabled: false, isEncrypted: false },
} as TSurvey);
mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id");
});
describe("getContactSurveyLink", () => {
test("creates a survey link with encrypted contact and survey IDs", () => {
const result = contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
test("creates a survey link with encrypted contact and survey IDs", async () => {
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
// Verify encryption was called for both IDs
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockContactId, ENCRYPTION_KEY);
@@ -85,11 +104,13 @@ describe("Contact Survey Link", () => {
ok: true,
data: `${getPublicDomain()}/c/${mockToken}`,
});
expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled();
});
test("adds expiration to the token when expirationDays is provided", () => {
test("adds expiration to the token when expirationDays is provided", async () => {
const expirationDays = 7;
contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
// Verify JWT sign was called with expiration
expect(jwt.sign).toHaveBeenCalledWith(
@@ -102,7 +123,55 @@ describe("Contact Survey Link", () => {
);
});
test("throws an error when ENCRYPTION_KEY is not available", async () => {
test("returns a not_found error when survey does not exist", async () => {
mockedGetSurvey.mockResolvedValue(null as unknown as TSurvey);
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, "unfound-survey-id");
expect(result).toEqual({
ok: false,
error: {
type: "not_found",
message: "Survey not found",
details: [{ field: "surveyId", issue: "not_found" }],
},
});
expect(mockedGetSurvey).toHaveBeenCalledWith("unfound-survey-id");
});
test("creates a link with unencrypted single use ID when enabled", async () => {
mockedGetSurvey.mockResolvedValue({
id: mockSurveyId,
singleUse: { enabled: true, isEncrypted: false },
} as TSurvey);
mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted");
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false);
expect(result).toEqual({
ok: true,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`,
});
});
test("creates a link with encrypted single use ID when enabled and encrypted", async () => {
mockedGetSurvey.mockResolvedValue({
id: mockSurveyId,
singleUse: { enabled: true, isEncrypted: true },
} as TSurvey);
mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted");
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true);
expect(result).toEqual({
ok: true,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-encrypted`,
});
});
test("returns an error when ENCRYPTION_KEY is not available", async () => {
// Reset modules so the new mock is used by the module under test
vi.resetModules();
// Remock constants to simulate missing ENCRYPTION_KEY
@@ -113,7 +182,7 @@ describe("Contact Survey Link", () => {
// Reimport the modules so they pick up the new mock
const { getContactSurveyLink } = await import("./contact-survey-link");
const result = getContactSurveyLink(mockContactId, mockSurveyId);
const result = await getContactSurveyLink(mockContactId, mockSurveyId);
expect(result).toEqual({
ok: false,
error: {
@@ -141,7 +210,7 @@ describe("Contact Survey Link", () => {
});
});
test("throws an error when token verification fails", () => {
test("returns an error when token verification fails", () => {
vi.mocked(jwt.verify).mockImplementation(() => {
throw new Error("Token verification failed");
});
@@ -157,7 +226,7 @@ describe("Contact Survey Link", () => {
}
});
test("throws an error when token has invalid format", () => {
test("returns an error when token has invalid format", () => {
// Mock JWT.verify to return an incomplete payload
vi.mocked(jwt.verify).mockReturnValue({
// Missing surveyId
@@ -178,7 +247,7 @@ describe("Contact Survey Link", () => {
}
});
test("throws an error when ENCRYPTION_KEY is not available", async () => {
test("returns an error when ENCRYPTION_KEY is not available", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
ENCRYPTION_KEY: undefined,

View File

@@ -1,17 +1,19 @@
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 { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getSurvey } from "@/modules/survey/lib/survey";
import jwt from "jsonwebtoken";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
export const getContactSurveyLink = (
export const getContactSurveyLink = async (
contactId: string,
surveyId: string,
expirationDays?: number
): Result<string, ApiErrorResponseV2> => {
): Promise<Result<string, ApiErrorResponseV2>> => {
if (!ENCRYPTION_KEY) {
return err({
type: "internal_server_error",
@@ -19,10 +21,27 @@ export const getContactSurveyLink = (
});
}
const survey = await getSurvey(surveyId);
if (!survey) {
return err({
type: "not_found",
message: "Survey not found",
details: [{ field: "surveyId", issue: "not_found" }],
});
}
const { enabled: isSingleUseEnabled, isEncrypted: isSingleUseEncrypted } = survey.singleUse ?? {};
// Encrypt the contact and survey IDs
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
let singleUseId: string | undefined;
if (isSingleUseEnabled) {
singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false);
}
// Create JWT payload with encrypted IDs
const payload = {
contactId: encryptedContactId,
@@ -43,7 +62,9 @@ export const getContactSurveyLink = (
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
// Return the personalized URL
return ok(`${getPublicDomain()}/c/${token}`);
return singleUseId
? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`)
: ok(`${getPublicDomain()}/c/${token}`);
};
// Validates and decrypts a contact survey JWT token
@@ -59,7 +80,10 @@ export const verifyContactSurveyToken = (
try {
// Verify the token
const decoded = jwt.verify(token, ENCRYPTION_KEY) as { contactId: string; surveyId: string };
const decoded = jwt.verify(token, ENCRYPTION_KEY) as {
contactId: string;
surveyId: string;
};
if (!decoded || !decoded.contactId || !decoded.surveyId) {
throw err("Invalid token format");

View File

@@ -501,11 +501,11 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string,
}
// Generate survey links for each contact
const contactLinks = contactsResult
.map((contact) => {
const contactLinks = await Promise.all(
contactsResult.map(async (contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
const surveyUrlResult = await getContactSurveyLink(contactId, surveyId, expirationDays);
if (!surveyUrlResult.ok) {
logger.error(
@@ -522,7 +522,8 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string,
expirationDays,
};
})
.filter(Boolean);
);
return contactLinks;
const filteredContactLinks = contactLinks.filter(Boolean);
return filteredContactLinks;
};

View File

@@ -313,32 +313,32 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Cal:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>

View File

@@ -11,30 +11,30 @@ interface TriggerCheckboxGroupProps {
allowChanges: boolean;
}
const triggers: {
title: string;
value: PipelineTriggers;
}[] = [
{
title: "environments.integrations.webhooks.response_created",
value: "responseCreated",
},
{
title: "environments.integrations.webhooks.response_updated",
value: "responseUpdated",
},
{
title: "environments.integrations.webhooks.response_finished",
value: "responseFinished",
},
];
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
selectedTriggers,
onCheckboxChange,
allowChanges,
}) => {
const { t } = useTranslate();
const triggers: {
title: string;
value: PipelineTriggers;
}[] = [
{
title: t("environments.integrations.webhooks.response_created"),
value: "responseCreated",
},
{
title: t("environments.integrations.webhooks.response_updated"),
value: "responseUpdated",
},
{
title: t("environments.integrations.webhooks.response_finished"),
value: "responseFinished",
},
];
return (
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
@@ -58,7 +58,7 @@ export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
}}
disabled={!allowChanges}
/>
<span className="ml-2">{t(trigger.title)}</span>
<span className="ml-2">{trigger.title}</span>
</label>
</div>
))}

View File

@@ -95,6 +95,5 @@ describe("getOrganizationAccessKeyDisplayName", () => {
test("returns tolgee string for other keys", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
expect(t).toHaveBeenCalledWith("otherKey");
});
});

View File

@@ -47,6 +47,6 @@ export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) =>
case "accessControl":
return t("environments.project.api_keys.access_control");
default:
return t(key);
return key;
}
};

View File

@@ -16,14 +16,6 @@ import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
const placements = [
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
{ name: "common.top_right", value: "topRight", disabled: false },
{ name: "common.top_left", value: "topLeft", disabled: false },
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
{ name: "common.centered_modal", value: "center", disabled: false },
];
interface EditPlacementProps {
project: Project;
environmentId: string;
@@ -40,6 +32,14 @@ type EditPlacementFormValues = z.infer<typeof ZProjectPlacementInput>;
export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) => {
const { t } = useTranslate();
const placements = [
{ name: t("common.bottom_right"), value: "bottomRight", disabled: false },
{ name: t("common.top_right"), value: "topRight", disabled: false },
{ name: t("common.top_left"), value: "topLeft", disabled: false },
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
{ name: t("common.centered_modal"), value: "center", disabled: false },
];
const form = useForm<EditPlacementFormValues>({
defaultValues: {
placement: project.placement,
@@ -102,7 +102,7 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
<Label
htmlFor={placement.value}
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t(placement.name)}
{placement.name}
</Label>
</div>
))}

View File

@@ -12,16 +12,16 @@ import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group"
import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react";
const placements = [
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
{ name: "common.top_right", value: "topRight", disabled: false },
{ name: "common.top_left", value: "topLeft", disabled: false },
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
{ name: "common.centered_modal", value: "center", disabled: false },
];
export const ProjectLookSettingsLoading = () => {
const { t } = useTranslate();
const placements = [
{ name: t("common.bottom_right"), value: "bottomRight", disabled: false },
{ name: t("common.top_right"), value: "topRight", disabled: false },
{ name: t("common.top_left"), value: "topLeft", disabled: false },
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
{ name: t("common.centered_modal"), value: "center", disabled: false },
];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
@@ -140,7 +140,7 @@ export const ProjectLookSettingsLoading = () => {
className={cn(
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
)}>
{t(placement.name)}
{placement.name}
</Label>
</div>
))}

View File

@@ -62,7 +62,7 @@ export const TemplateFilters = ({
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
)}>
{t(filter.label)}
{filter.label}
</button>
))}
</div>

View File

@@ -40,7 +40,7 @@ const getChannelTag = (channels: NonNullabeChannel[] | undefined, t: TFnType): s
const labels = channels
.map((channel) => {
const label = getLabel(channel);
if (label) return t(label);
if (label) return label;
return undefined;
})
.filter((label): label is string => !!label)
@@ -78,12 +78,12 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
// if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce".
if (selectedFilter[1] !== null) {
const industry = getIndustryMapping(t).find((industry) => industry.value === selectedFilter[1]);
if (industry) return t(industry.label);
if (industry) return industry.label;
}
if (!industries || industries.length === 0) return undefined;
return industries.length > 1
? t("environments.surveys.templates.multiple_industries")
: t(getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label ?? "");
: getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label;
};
const industryTag = useMemo(
@@ -93,7 +93,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
return (
<div className="flex flex-wrap gap-1.5">
<div className={cn("rounded border px-1.5 py-0.5 text-xs", roleBasedStyling)}>{t(roleTag ?? "")}</div>
<div className={cn("rounded border px-1.5 py-0.5 text-xs", roleBasedStyling)}>{roleTag}</div>
{industryTag && (
<div
className={cn("rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500")}>

View File

@@ -42,7 +42,7 @@ export const Placement = ({
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
<Label htmlFor={placement.value} className="text-slate-900">
{t(placement.name)}
{placement.name}
</Label>
</div>
))}

View File

@@ -166,9 +166,9 @@ export const RecontactOptionsCard = ({
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
/>
<div>
<p className="font-semibold text-slate-700">{t(option.name)}</p>
<p className="font-semibold text-slate-700">{option.name}</p>
<p className="mt-2 text-xs font-normal text-slate-600">{t(option.description)}</p>
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
</div>
</Label>
{option.id === "displaySome" && localSurvey.displayOption === "displaySome" && (

View File

@@ -420,6 +420,8 @@ export const ResponseOptionsCard = ({
/>
</div>
</AdvancedOptionToggle>
{/* Protect Survey with Pin */}
<AdvancedOptionToggle
htmlId="protectSurveyWithPin"
isChecked={isPinProtectionEnabled}
@@ -442,6 +444,7 @@ export const ResponseOptionsCard = ({
defaultValue={localSurvey.pin ? localSurvey.pin : undefined}
onKeyDown={handleSurveyPinInputKeyDown}
onChange={(e) => handleProtectSurveyPinChange(e.target.value)}
maxLength={4}
/>
{verifyProtectWithPinError && (
<p className="pt-1 text-sm text-red-700">{verifyProtectWithPinError}</p>

View File

@@ -1,8 +1,9 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { rateLimit } from "@/lib/utils/rate-limit";
import { validateInputs } from "@/lib/utils/validate";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { sendFollowUpEmail } from "@/modules/survey/follow-ups/lib/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { FollowUpResult, FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
@@ -16,11 +17,6 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 50, // max 50 calls per org per hour
});
const evaluateFollowUp = async (
followUp: TSurveyFollowUp,
survey: TSurvey,
@@ -191,7 +187,7 @@ export const sendFollowUpsForResponse = async (
// Check rate limit
try {
await limiter(organization.id);
await applyRateLimit(rateLimitConfigs.actions.surveyFollowUp, organization.id);
} catch {
return err({
code: FollowUpSendError.RATE_LIMIT_EXCEEDED,

View File

@@ -79,7 +79,7 @@ export const LinkSurvey = ({
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
const [autoFocus, setAutofocus] = useState(false);
const [autoFocus, setAutoFocus] = useState(false);
const hasFinishedSingleUseResponse = useMemo(() => {
if (singleUseResponse?.finished) {
return true;
@@ -91,7 +91,7 @@ export const LinkSurvey = ({
// Not in an iframe, enable autofocus on input fields.
useEffect(() => {
if (window.self === window.top) {
setAutofocus(true);
setAutoFocus(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
}, []);
@@ -121,7 +121,7 @@ export const LinkSurvey = ({
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
}
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified") {
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
if (emailVerificationStatus === "fishy") {
return (
<VerifyEmail

View File

@@ -64,7 +64,7 @@ export const renderSurvey = async ({
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
surveyClosedMessage={survey.surveyClosedMessage ?? undefined}
project={project || undefined}
/>
);

View File

@@ -3,6 +3,7 @@ import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getTranslate } from "@/tolgee/server";
@@ -14,6 +15,7 @@ interface ContactSurveyPageProps {
jwt: string;
}>;
searchParams: Promise<{
suId?: string;
verify?: string;
lang?: string;
embed?: string;
@@ -46,9 +48,10 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
const { jwt } = params;
const { preview } = searchParams;
const { preview, suId } = searchParams;
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
@@ -62,6 +65,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
// So we show SurveyInactive without project data (shows branding by default for backward compatibility)
return <SurveyInactive status="link invalid" />;
}
const { surveyId, contactId } = result.data;
const existingResponse = await getExistingContactResponse(surveyId, contactId)();
@@ -81,10 +85,26 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
notFound();
}
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
singleUseId = validatedSingleUseId;
}
return renderSurvey({
survey,
searchParams,
contactId,
isPreview,
singleUseId,
});
};

View File

@@ -1,11 +1,16 @@
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getEmailVerificationDetails } from "./helper";
import { checkAndValidateSingleUseId, getEmailVerificationDetails } from "./helper";
vi.mock("@/lib/jwt", () => ({
verifyTokenForLinkSurvey: vi.fn(),
}));
vi.mock("@/app/lib/singleUseSurveys", () => ({
validateSurveySingleUseId: vi.fn(),
}));
describe("getEmailVerificationDetails", () => {
const mockedVerifyTokenForLinkSurvey = vi.mocked(verifyTokenForLinkSurvey);
const testSurveyId = "survey-123";
@@ -54,3 +59,82 @@ describe("getEmailVerificationDetails", () => {
expect(mockedVerifyTokenForLinkSurvey).toHaveBeenCalledWith(testToken, testSurveyId);
});
});
describe("checkAndValidateSingleUseId", () => {
const mockedValidateSurveySingleUseId = vi.mocked(validateSurveySingleUseId);
beforeEach(() => {
vi.resetAllMocks();
});
test("returns null when no suid is provided", () => {
const result = checkAndValidateSingleUseId();
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
test("returns null when suid is empty string", () => {
const result = checkAndValidateSingleUseId("");
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
test("returns suid as-is when isEncrypted is false", () => {
const testSuid = "plain-suid-123";
const result = checkAndValidateSingleUseId(testSuid, false);
expect(result).toBe(testSuid);
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
test("returns suid as-is when isEncrypted is not provided (defaults to false)", () => {
const testSuid = "plain-suid-123";
const result = checkAndValidateSingleUseId(testSuid);
expect(result).toBe(testSuid);
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
test("returns validated suid when isEncrypted is true and validation succeeds", () => {
const encryptedSuid = "encrypted-suid-123";
const validatedSuid = "validated-suid-456";
mockedValidateSurveySingleUseId.mockReturnValueOnce(validatedSuid);
const result = checkAndValidateSingleUseId(encryptedSuid, true);
expect(result).toBe(validatedSuid);
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
});
test("returns null when isEncrypted is true and validation returns undefined", () => {
const encryptedSuid = "invalid-encrypted-suid";
mockedValidateSurveySingleUseId.mockReturnValueOnce(undefined);
const result = checkAndValidateSingleUseId(encryptedSuid, true);
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
});
test("returns null when isEncrypted is true and validation returns empty string", () => {
const encryptedSuid = "invalid-encrypted-suid";
mockedValidateSurveySingleUseId.mockReturnValueOnce("");
const result = checkAndValidateSingleUseId(encryptedSuid, true);
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
});
test("returns null when isEncrypted is true and validation returns null", () => {
const encryptedSuid = "invalid-encrypted-suid";
mockedValidateSurveySingleUseId.mockReturnValueOnce(null as any);
const result = checkAndValidateSingleUseId(encryptedSuid, true);
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
});
});

View File

@@ -1,4 +1,5 @@
import "server-only";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
interface emailVerificationDetails {
@@ -25,3 +26,15 @@ export const getEmailVerificationDetails = async (
}
}
};
export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false): string | null => {
if (!suid?.trim()) return null;
if (isEncrypted) {
const validatedSingleUseId = validateSurveySingleUseId(suid);
if (!validatedSingleUseId) return null;
return validatedSingleUseId;
}
return suid;
};

View File

@@ -13,6 +13,16 @@ import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { LinkSurveyPage, generateMetadata } from "./page";
// Mock server-side constants to prevent client-side access
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_RECAPTCHA_CONFIGURED: false,
RECAPTCHA_SITE_KEY: "test-key",
IMPRINT_URL: "https://example.com/imprint",
PRIVACY_URL: "https://example.com/privacy",
ENCRYPTION_KEY: "0".repeat(32),
}));
// Mock dependencies
vi.mock("next/navigation", () => ({
notFound: vi.fn(),

View File

@@ -1,7 +1,7 @@
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import type { Metadata } from "next";
@@ -60,23 +60,13 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
// check if the single use id is present for single use surveys
if (!suId) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
if (!validatedSingleUseId) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
// if encryption is enabled, validate the single use id
let validatedSingleUseId: string | undefined = undefined;
if (isSingleUseSurveyEncrypted) {
validatedSingleUseId = validateSurveySingleUseId(suId);
if (!validatedSingleUseId) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
}
// if encryption is disabled, use the suId as is
singleUseId = validatedSingleUseId ?? suId;
singleUseId = validatedSingleUseId;
}
let singleUseResponse;

View File

@@ -1,8 +1,7 @@
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { TSortOption } from "@formbricks/types/surveys/types";
import { SortOption } from "./sort-option";
// Mock dependencies
@@ -21,7 +20,7 @@ vi.mock("@tolgee/react", () => ({
describe("SortOption", () => {
const mockOption: TSortOption = {
label: "test.sort.option",
value: "testValue",
value: "createdAt",
};
const mockHandleSortChange = vi.fn();
@@ -32,14 +31,14 @@ describe("SortOption", () => {
});
test("renders correctly with the option label", () => {
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
expect(screen.getByText("test.sort.option")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-menu-item")).toBeInTheDocument();
});
test("applies correct styling when option is selected", () => {
render(<SortOption option={mockOption} sortBy="testValue" handleSortChange={mockHandleSortChange} />);
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
expect(circleIndicator).toHaveClass("bg-brand-dark");
@@ -47,11 +46,10 @@ describe("SortOption", () => {
});
test("applies correct styling when option is not selected", () => {
render(
<SortOption option={mockOption} sortBy="differentValue" handleSortChange={mockHandleSortChange} />
);
render(<SortOption option={mockOption} sortBy="updatedAt" handleSortChange={mockHandleSortChange} />);
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
expect(circleIndicator).toHaveClass("border-white");
expect(circleIndicator).not.toHaveClass("bg-brand-dark");
expect(circleIndicator).not.toHaveClass("outline-brand-dark");
});
@@ -59,7 +57,7 @@ describe("SortOption", () => {
test("calls handleSortChange when clicked", async () => {
const user = userEvent.setup();
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
await user.click(screen.getByTestId("dropdown-menu-item"));
expect(mockHandleSortChange).toHaveBeenCalledTimes(1);
@@ -67,7 +65,7 @@ describe("SortOption", () => {
});
test("translates the option label", () => {
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
// The mock for useTranslate returns the key itself, so we're checking if translation was attempted
expect(screen.getByText(mockOption.label)).toBeInTheDocument();

View File

@@ -1,7 +1,6 @@
"use client";
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
interface SortOptionProps {
@@ -11,7 +10,6 @@ interface SortOptionProps {
}
export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps) => {
const { t } = useTranslate();
return (
<DropdownMenuItem
key={option.label}
@@ -22,7 +20,7 @@ export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<span
className={`h-4 w-4 rounded-full border ${sortBy === option.value ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
<p className="font-normal text-white">{t(option.label)}</p>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
);

View File

@@ -219,16 +219,18 @@ export const SurveyDropDownMenu = ({
{t("common.preview_survey")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className="flex w-full items-center"
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
{!survey.singleUse?.enabled && (
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className="flex w-full items-center"
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
)}
</>
)}
{!isSurveyCreationDeletionDisabled && (

View File

@@ -4,11 +4,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { TFilterOption } from "@formbricks/types/surveys/types";
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
// Mock dependencies
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
// Mock UI components
vi.mock("@/modules/ui/components/checkbox", () => ({
Checkbox: ({ checked, className }) => (

View File

@@ -7,7 +7,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { TFilterOption } from "@formbricks/types/surveys/types";
@@ -30,7 +29,6 @@ export const SurveyFilterDropdown = ({
isOpen,
toggleDropdown,
}: SurveyFilterDropdownProps) => {
const { t } = useTranslate();
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
@@ -56,7 +54,7 @@ export const SurveyFilterDropdown = ({
checked={selectedOptions.includes(option.value)}
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
/>
<p className="font-normal text-white">{t(option.label)}</p>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
))}

View File

@@ -183,7 +183,7 @@ export const SurveyFilters = ({
<span className="text-sm">
{t("common.sort_by")}:{" "}
{getSortOptions(t).find((option) => option.value === sortBy)
? t(getSortOptions(t).find((option) => option.value === sortBy)?.label ?? "")
? getSortOptions(t).find((option) => option.value === sortBy)?.label
: ""}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4" />

View File

@@ -91,7 +91,7 @@ export const SelectedRowSettings = <T,>({
<>
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
<div className="lowercase">
{selectedRowCount} {t(`common.${type}s`)} {t("common.selected")}
{`${selectedRowCount} ${type === "response" ? t("common.responses") : t("common.contacts")} ${t("common.selected")}`}
</div>
<Separator />
<Button
@@ -146,7 +146,7 @@ export const SelectedRowSettings = <T,>({
<DeleteDialog
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
deleteWhat={t(`common.${type}`)}
deleteWhat={type === "response" ? t("common.responses") : t("common.contacts")}
onDelete={handleDelete}
isDeleting={isDeleting}
text={deleteDialogText}

View File

@@ -28,6 +28,7 @@ import { ToolbarPlugin } from "./toolbar-plugin";
- bold
- italic
- link
- underline
*/
export type TextEditorProps = {
getText: () => string;

View File

@@ -147,6 +147,7 @@ vi.mock("lucide-react", () => ({
Bold: () => <span data-testid="bold-icon">Bold</span>,
Italic: () => <span data-testid="italic-icon">Italic</span>,
Link: () => <span data-testid="link-icon">Link</span>,
Underline: () => <span data-testid="underline-icon">Underline</span>,
ChevronDownIcon: () => <span data-testid="chevron-icon">ChevronDown</span>,
}));
@@ -186,6 +187,7 @@ describe("ToolbarPlugin", () => {
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
});
@@ -218,20 +220,57 @@ describe("ToolbarPlugin", () => {
});
test("excludes toolbar items when specified", () => {
render(
const { rerender } = render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
excludedToolbarItems={["bold", "italic"]}
excludedToolbarItems={["bold", "italic", "underline"]}
/>
);
// Should not render bold and italic buttons but should render link
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
// Rerender with different excluded items
rerender(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
excludedToolbarItems={["blockType", "link"]}
/>
);
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
});
test("excludes all toolbar items when specified", () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
excludedToolbarItems={["blockType", "bold", "italic", "underline", "link"]}
/>
);
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
});
test("handles firstRender and updateTemplate props", () => {
@@ -253,4 +292,122 @@ describe("ToolbarPlugin", () => {
// the component renders without errors when these props are provided
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
});
describe("User Interactions", () => {
test("dispatches bold format command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const boldIcon = screen.getByTestId("bold-icon");
const boldButton = boldIcon.parentElement;
expect(boldButton).toBeInTheDocument();
expect(boldButton).not.toBeNull();
await userEvent.click(boldButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "bold");
});
test("dispatches italic format command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const italicIcon = screen.getByTestId("italic-icon");
const italicButton = italicIcon.parentElement;
expect(italicButton).toBeInTheDocument();
expect(italicButton).not.toBeNull();
await userEvent.click(italicButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "italic");
});
test("dispatches underline format command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const underlineIcon = screen.getByTestId("underline-icon");
const underlineButton = underlineIcon.parentElement;
expect(underlineButton).toBeInTheDocument();
expect(underlineButton).not.toBeNull();
await userEvent.click(underlineButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "underline");
});
test("dispatches link command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const linkIcon = screen.getByTestId("link-icon");
const linkButton = linkIcon.parentElement;
expect(linkButton).toBeInTheDocument();
expect(linkButton).not.toBeNull();
await userEvent.click(linkButton!);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("toggleLink", {
url: "https://",
});
});
test("dispatches numbered list command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
await userEvent.click(dropdownTrigger);
const numberedListButton = screen.getAllByTestId("button")[1]; // ol
await userEvent.click(numberedListButton);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertOrderedList", undefined);
});
test("dispatches bulleted list command on click", async () => {
render(
<ToolbarPlugin
getText={() => "Sample text"}
setText={vi.fn()}
editable={true}
container={document.createElement("div")}
/>
);
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
await userEvent.click(dropdownTrigger);
const bulletedListButton = screen.getAllByTestId("button")[2]; // ul
await userEvent.click(bulletedListButton);
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertUnorderedList", undefined);
});
});
});

View File

@@ -29,11 +29,12 @@ import {
$getSelection,
$insertNodes,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
FORMAT_TEXT_COMMAND,
PASTE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from "lexical";
import { COMMAND_PRIORITY_CRITICAL, PASTE_COMMAND } from "lexical";
import { Bold, ChevronDownIcon, Italic, Link } from "lucide-react";
import { Bold, ChevronDownIcon, Italic, Link, Underline } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AddVariablesDropdown } from "./add-variables-dropdown";
@@ -235,6 +236,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
const [isLink, setIsLink] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
// save ref to setText to use it in event listeners safely
const setText = useRef<any>(props.setText);
@@ -334,7 +336,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
}
setIsBold(selection.hasFormat("bold"));
setIsItalic(selection.hasFormat("italic"));
setIsUnderline(selection.hasFormat("underline"));
const node = getSelectedNode(selection);
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
@@ -459,95 +461,94 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
if (!props.editable) return <></>;
const items = [
{
key: "bold",
icon: Bold,
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"),
active: isBold,
},
{
key: "italic",
icon: Italic,
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"),
active: isItalic,
},
{
key: "underline",
icon: Underline,
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"),
active: isUnderline,
},
{
key: "link",
icon: Link,
onClick: insertLink,
active: isLink,
},
];
return (
<div className="toolbar flex" ref={toolbarRef}>
<>
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
<>
<DropdownMenu>
<DropdownMenuTrigger className="text-subtle">
<>
<span className={"icon" + blockType} />
<span className="text text-default hidden sm:flex">
{blockTypeToBlockName[blockType as keyof BlockType]}
</span>
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
</>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{Object.keys(blockTypeToBlockName).map((key) => {
return (
<DropdownMenuItem key={key}>
<Button
type="button"
onClick={() => format(key)}
className={cn(
"w-full rounded-none focus:ring-0",
blockType === key ? "bg-subtle w-full" : ""
)}>
<>
<span className={"icon block-type " + key} />
<span>{blockTypeToBlockName[key]}</span>
</>
</Button>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<>
{!props.excludedToolbarItems?.includes("bold") && (
<Button
variant="ghost"
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
}}
className={isBold ? "bg-subtle active-button" : "inactive-button"}>
<Bold />
</Button>
)}
{!props.excludedToolbarItems?.includes("italic") && (
<Button
variant="ghost"
type="button"
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
}}
className={isItalic ? "bg-subtle active-button" : "inactive-button"}>
<Italic />
</Button>
)}
{!props.excludedToolbarItems?.includes("link") && (
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
<DropdownMenu>
<DropdownMenuTrigger className="text-subtle">
<>
<Button
variant="ghost"
type="button"
onClick={insertLink}
className={isLink ? "bg-subtle active-button" : "inactive-button"}>
<Link />
</Button>
{isLink ? (
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)
) : (
<></>
)}
<span className={cn("icon", blockType)} />
<span className="text text-default hidden sm:flex">
{blockTypeToBlockName[blockType as keyof BlockType]}
</span>
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
</>
)}
</>
{props.variables && (
<div className="ml-auto">
<AddVariablesDropdown
addVariable={addVariable}
isTextEditor={true}
variables={props.variables || []}
/>
</div>
)}
</>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{Object.keys(blockTypeToBlockName).map((key) => {
return (
<DropdownMenuItem key={key}>
<Button
type="button"
onClick={() => format(key)}
className={cn(
"w-full rounded-none focus:ring-0",
blockType === key ? "bg-subtle w-full" : ""
)}>
<>
<span className={cn("icon block-type", key)} />
<span>{blockTypeToBlockName[key]}</span>
</>
</Button>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{items.map(({ key, icon: Icon, onClick, active }) =>
!props.excludedToolbarItems?.includes(key) ? (
<Button
key={key}
variant="ghost"
type="button"
onClick={onClick}
className={active ? "bg-subtle active-button" : "inactive-button"}>
<Icon />
</Button>
) : null
)}
{isLink &&
!props.excludedToolbarItems?.includes("link") &&
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)}
{props.variables && (
<div className="ml-auto">
<AddVariablesDropdown
addVariable={addVariable}
isTextEditor={true}
variables={props.variables || []}
/>
</div>
)}
</div>
);
};

View File

@@ -20,5 +20,6 @@ export const exampleTheme = {
text: {
bold: "fb-editor-text-bold",
italic: "fb-editor-text-italic",
underline: "fb-editor-text-underline",
},
};

View File

@@ -6,6 +6,10 @@
font-style: italic !important;
}
.fb-editor-text-underline {
text-decoration: underline !important;
}
.fb-editor-link {
text-decoration: underline !important;
}

View File

@@ -274,7 +274,7 @@ export const InputCombobox = ({
)}
<CommandList className="m-1">
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
{emptyDropdownText ? t(emptyDropdownText) : t("environments.surveys.edit.no_option_found")}
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
</CommandEmpty>
{options && options.length > 0 && (
<CommandGroup>

View File

@@ -79,7 +79,6 @@
"@tolgee/format-icu": "6.2.5",
"@tolgee/react": "6.2.5",
"@ungap/structured-clone": "1.3.0",
"@unkey/ratelimit": "0.5.5",
"@vercel/functions": "2.0.2",
"@vercel/og": "0.6.8",
"bcryptjs": "3.0.2",

View File

@@ -104,7 +104,7 @@ This guide explains the settings you need to use to configure SAML with your Ide
<Step title="Scroll to the bottom and copy the IDP metadata">
<img src="/images/development/guides/auth-and-provision/okta/idp-metadata.webp" />
</Step>
<Step title="Copy the IDP metadata and paste it in the `connection.xml` file in the `formbricks/saml-connection` directory" />
<Step title="Copy the IDP metadata and paste it in the `connection.xml` file in the `formbricks/saml-connection` (use `formbricks/apps/web/saml-connection` for development) directory" />
</Steps>
That's it. Now when you try to login with SSO, your application on Okta will handle the authentication.

View File

@@ -85,7 +85,7 @@ To configure SAML SSO in Formbricks, follow these steps:
</Step>
<Step title="Metadata Setup">
Create a file called `connection.xml` in your self-hosted Formbricks instance's `formbricks/saml-connection` directory and paste the XML metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
Create a file called `connection.xml` in your self-hosted Formbricks instance's `formbricks/saml-connection` (use `formbricks/apps/web/saml-connection` for development) directory and paste the XML metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
</Step>
<Step title="Restart Formbricks">

View File

@@ -35,6 +35,10 @@ Formbricks supports a powerful domain separation feature that allows you to serv
- Restrict admin functionality to the private domain
- Ensure authentication and sensitive operations only occur on the private domain
<Warning>
Publicly available images uploaded before the domain split (logos, images in Picture Select questions, etc.) will continue to be served from the Private Domain. **To protect your Private Domain, please reupload public images after the successful setup of the domain split.**
</Warning>
### Why Use Domain Split?
- **Enhanced Security**: Separate public-facing surveys from your admin interface
@@ -145,4 +149,13 @@ If `PUBLIC_URL` is not set:
- The system behaves as a single domain setup
- No domain separation occurs
### Reverting to a Single Domain
While it's possible to revert to a Single Domain setup, please take into account the following:
1. All image links and uploaded files are stored in the database, so they will continue to link to the Public Domain **hence the links will break.** You'll need to reupload the images, like you did when you set up the domain split.
2. Any survey link shared to with the Public Domain **will break.**
3. API calls to the Public Domain **will break**, unless updated.
As of now, Formbricks does not provide an automated migration between domain setups.
If you have any questions or require help, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).

View File

@@ -54,7 +54,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
@@ -62,7 +62,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | |
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |

View File

@@ -0,0 +1,18 @@
// packages/js-core/.prettierrc.cjs
const base = require("../config-prettier/prettier-preset");
module.exports = {
...base,
plugins: [
"@trivago/prettier-plugin-sort-imports",
],
importOrder: [
"^vitest$", // 1⃣ vitest first
"<THIRD_PARTY_MODULES>", // 2⃣ then other externals
"^@/.*$", // 3⃣ then anything under @/
"^\\.\\/__mocks__\\/.*$", // 4⃣ then anything under ./__mocks__/
"^[./]", // 5⃣ finally all other relative imports
],
importOrderSortSpecifiers: true,
};

View File

@@ -44,6 +44,7 @@
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "3.1.3",
"terser": "5.39.1",

View File

@@ -1,7 +1,7 @@
import { wrapThrowsAsync } from "@/lib/common/utils";
import { ApiResponse, ApiSuccessResponse, CreateOrUpdateUserResponse } from "@/types/api";
import { TEnvironmentState } from "@/types/config";
import { ApiErrorResponse, Result, err, ok } from "@/types/error";
import { type ApiResponse, type ApiSuccessResponse, type CreateOrUpdateUserResponse } from "@/types/api";
import { type TEnvironmentState } from "@/types/config";
import { type ApiErrorResponse, type Result, err, ok } from "@/types/error";
export const makeRequest = async <T>(
appUrl: string,

View File

@@ -1,7 +1,7 @@
// api.test.ts
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ApiClient, makeRequest } from "@/lib/common/api";
import type { TEnvironmentState } from "@/types/config";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock fetch
const mockFetch = vi.fn();

View File

@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
import { checkSetup } from "@/lib/common/status";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type Result } from "@/types/error";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock the setup module so we can control checkSetup()
vi.mock("@/lib/common/status", () => ({

View File

@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/unbound-method -- required for mocking */
// config.test.ts
import { mockConfig } from "./__mocks__/config.mock";
import { type Mock, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
import type { TConfig, TConfigUpdateInput } from "@/types/config";
import { type Mock, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { mockConfig } from "./__mocks__/config.mock";
// Define mocks outside of any describe block
const getItemMock = localStorage.getItem as unknown as Mock;

View File

@@ -1,4 +1,5 @@
// event-listeners.test.ts
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
import {
addCleanupEventListeners,
addEventListeners,
@@ -8,7 +9,6 @@ import {
import * as environmentState from "@/lib/environment/state";
import * as pageUrlEventListeners from "@/lib/survey/no-code-action";
import * as userState from "@/lib/user/state";
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
// 1) Mock all the imported dependencies

View File

@@ -1,7 +1,7 @@
// file-upload.test.ts
import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageAPI } from "@/lib/common/file-upload";
import type { TUploadFileConfig } from "@/types/storage";
import { beforeEach, describe, expect, test, vi } from "vitest";
// A global fetch mock so we can capture fetch calls.
// Alternatively, use `vi.stubGlobal("fetch", ...)`.

View File

@@ -1,8 +1,6 @@
// logger.test.ts
import { Logger } from "@/lib/common/logger";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// adjust import path as needed
import { Logger } from "@/lib/common/logger";
describe("Logger", () => {
let logger: Logger;

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method -- required for testing */
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
@@ -6,10 +7,10 @@ import { Logger } from "@/lib/common/logger";
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
import { setIsSetup } from "@/lib/common/status";
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
import type * as Utils from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
import { sendUpdatesToBackend } from "@/lib/user/update";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const setItemMock = localStorage.setItem as unknown as Mock;
@@ -50,8 +51,9 @@ vi.mock("@/lib/environment/state", () => ({
// 6) Mock filterSurveys
vi.mock("@/lib/common/utils", async (importOriginal) => {
const originalModule = await importOriginal<typeof Utils>();
return {
...(await importOriginal<typeof import("@/lib/common/utils")>()),
...originalModule,
filterSurveys: vi.fn(),
isNowExpired: vi.fn(),
};

View File

@@ -1,5 +1,5 @@
import { checkSetup, getIsSetup, setIsSetup } from "@/lib/common/status";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { checkSetup, getIsSetup, setIsSetup } from "@/lib/common/status";
describe("checkSetup()", () => {
beforeEach(() => {

View File

@@ -1,5 +1,5 @@
import { TimeoutStack } from "@/lib/common/timeout-stack";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TimeoutStack } from "@/lib/common/timeout-stack";
// Using vitest, we don't need to manually declare globals

View File

@@ -1,4 +1,5 @@
// utils.test.ts
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mockProjectId, mockSurveyId } from "@/lib/common/tests/__mocks__/config.mock";
import {
checkUrlMatch,
@@ -26,7 +27,6 @@ import type {
TUserState,
} from "@/types/config";
import { type TActionClassNoCodeConfig, type TActionClassPageUrlRule } from "@/types/survey";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
const mockSurveyId2 = "qo9rwjmms42hoy3k85fp8vgu";

View File

@@ -1,4 +1,5 @@
// state.test.ts
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ApiClient } from "@/lib/common/api";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
@@ -9,7 +10,6 @@ import {
fetchEnvironmentState,
} from "@/lib/environment/state";
import type { TEnvironmentState } from "@/types/config";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock the ApiClient so we can control environment.getEnvironmentState
vi.mock("@/lib/common/api", () => ({

View File

@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { trackAction, trackCodeAction, trackNoCodeAction } from "@/lib/survey/action";
import { SurveyStore } from "@/lib/survey/store";
import { triggerSurvey } from "@/lib/survey/widget";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/lib/common/config", () => ({
Config: {

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock functions are unbound */
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { checkSetup } from "@/lib/common/status";
import { TimeoutStack } from "@/lib/common/timeout-stack";
@@ -17,8 +18,7 @@ import {
removeScrollDepthListener,
} from "@/lib/survey/no-code-action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
import { TActionClassNoCodeConfig } from "@/types/survey";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type TActionClassNoCodeConfig } from "@/types/survey";
vi.mock("@/lib/common/config", () => ({
Config: {

View File

@@ -1,7 +1,7 @@
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
import { SurveyStore } from "@/lib/survey/store";
import type { TEnvironmentStateSurvey } from "@/types/config";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { SurveyStore } from "@/lib/survey/store";
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
import type { TEnvironmentStateSurvey } from "@/types/config";
describe("SurveyStore", () => {
let store: SurveyStore;

View File

@@ -1,10 +1,10 @@
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
import * as widget from "@/lib/survey/widget";
import { type TEnvironmentStateSurvey } from "@/types/config";
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/lib/common/config", () => ({
Config: {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { setAttributes } from "@/lib/user/attribute";
import { UpdateQueue } from "@/lib/user/update-queue";
import { beforeEach, describe, expect, test, vi } from "vitest";
export const mockAttributes = {
name: "John Doe",

View File

@@ -1,6 +1,6 @@
import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const mockUserId = "user_123";

View File

@@ -1,9 +1,9 @@
import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock";
import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock";
import { sendUpdates } from "@/lib/user/update";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest";
// Mock dependencies
vi.mock("@/lib/common/config", () => ({

Some files were not shown because too many files have changed in this diff Show More