mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-28 09:20:49 -06:00
Compare commits
19 Commits
tolgee-war
...
docs-quota
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735c180eaf | ||
|
|
342fe89f0c | ||
|
|
a0f334b300 | ||
|
|
a9f635b768 | ||
|
|
d385b4a0d6 | ||
|
|
5e825413d2 | ||
|
|
8c3e816ccd | ||
|
|
6ddc91ee85 | ||
|
|
14023ca8a9 | ||
|
|
385e8a4262 | ||
|
|
e358104f7c | ||
|
|
c8e9194ab6 | ||
|
|
bebe29815d | ||
|
|
7f40502c94 | ||
|
|
5fb5215680 | ||
|
|
19b80ff042 | ||
|
|
2dfdba2acf | ||
|
|
f7842789de | ||
|
|
59bdd5f065 |
24
.cursor/rules/documentations.mdc
Normal file
24
.cursor/rules/documentations.mdc
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
||||
- In all headlines, only capitalize the current feature and nothing else, NO Camel Case
|
||||
- Update the mint.json file and add the nav item at the correct position corresponding to where the MDX file is located
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
@@ -80,8 +80,8 @@ S3_ENDPOINT_URL=
|
||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||
S3_FORCE_PATH_STYLE=0
|
||||
|
||||
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
|
||||
# SURVEY_URL=https://survey.example.com
|
||||
# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
|
||||
# PUBLIC_URL=https://survey.example.com
|
||||
|
||||
#####################
|
||||
# Disable Features #
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -73,3 +73,4 @@ infra/terraform/.terraform/
|
||||
/.idea/
|
||||
/*.iml
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
@@ -40,7 +40,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
@@ -53,7 +53,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
@@ -67,7 +67,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
|
||||
@@ -12,14 +12,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||
|
||||
interface ConnectWithFormbricksProps {
|
||||
environment: TEnvironment;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
widgetSetupCompleted: boolean;
|
||||
channel: TProjectConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
environment,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
widgetSetupCompleted,
|
||||
channel,
|
||||
}: ConnectWithFormbricksProps) => {
|
||||
@@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<OnboardingSetupInstructions
|
||||
environmentId={environment.id}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={publicDomain}
|
||||
channel={channel}
|
||||
widgetSetupCompleted={widgetSetupCompleted}
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => {
|
||||
// Provide some default props for testing
|
||||
const defaultProps = {
|
||||
environmentId: "env-123",
|
||||
webAppUrl: "https://example.com",
|
||||
publicDomain: "https://example.com",
|
||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||
widgetSetupCompleted: false,
|
||||
};
|
||||
|
||||
@@ -18,14 +18,14 @@ const tabs = [
|
||||
|
||||
interface OnboardingSetupInstructionsProps {
|
||||
environmentId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
channel: TProjectConfigChannel;
|
||||
widgetSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingSetupInstructions = ({
|
||||
environmentId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
channel,
|
||||
widgetSetupCompleted,
|
||||
}: OnboardingSetupInstructionsProps) => {
|
||||
@@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({
|
||||
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
|
||||
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
@@ -30,6 +30,8 @@ const Page = async (props: ConnectPageProps) => {
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
||||
@@ -39,7 +41,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
</div>
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
widgetSetupCompleted={environment.appSetupCompleted}
|
||||
channel={channel}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
|
||||
@@ -30,6 +30,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
test("should re-export SingleContactPage", () => {
|
||||
expect(Page).toBe(SingleContactPage);
|
||||
|
||||
@@ -220,6 +220,9 @@ describe("MainNavigation", () => {
|
||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Find the avatar and get its parent div which acts as the trigger
|
||||
@@ -240,6 +243,9 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
// Verify localStorage.removeItem is called with the correct key
|
||||
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
|
||||
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
@@ -247,9 +253,13 @@ describe("MainNavigation", () => {
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
// Clean up spy
|
||||
removeItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
@@ -390,6 +391,8 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
|
||||
@@ -29,6 +29,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://example.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("AppConnectionPage Re-export", () => {
|
||||
test("should re-export AppConnectionPage correctly", () => {
|
||||
expect(AppConnectionPage).toBe(OriginalAppConnectionPage);
|
||||
|
||||
@@ -29,6 +29,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GeneralSettingsPage re-export", () => {
|
||||
test("should re-export GeneralSettingsPage component", () => {
|
||||
expect(Page).toBe(GeneralSettingsPage);
|
||||
|
||||
@@ -29,6 +29,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ProjectLookSettingsPage re-export", () => {
|
||||
test("should re-export ProjectLookSettingsPage component", () => {
|
||||
expect(Page).toBe(ProjectLookSettingsPage);
|
||||
|
||||
@@ -34,6 +34,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("TeamsPage re-export", () => {
|
||||
test("should re-export TeamsPage component", () => {
|
||||
expect(Page).toBe(TeamsPage);
|
||||
|
||||
@@ -49,6 +49,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||
vi.mock("@/app/lib/surveys/surveys");
|
||||
|
||||
@@ -20,7 +20,7 @@ interface ResponsePageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
responsesPerPage: number;
|
||||
@@ -32,7 +32,7 @@ export const ResponsePage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
user,
|
||||
environmentTags,
|
||||
responsesPerPage,
|
||||
@@ -155,7 +155,7 @@ export const ResponsePage = ({
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} />}
|
||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
|
||||
</div>
|
||||
<ResponseDataView
|
||||
survey={survey}
|
||||
|
||||
@@ -3,7 +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 { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
@@ -65,8 +65,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getSurveyUrl", () => ({
|
||||
getSurveyDomain: vi.fn(),
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/service", () => ({
|
||||
@@ -160,7 +160,7 @@ const mockEnvironment = {
|
||||
|
||||
const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: mockEnvironmentId } as unknown as TTag];
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
const mockSurveyDomain = "http://customdomain.com";
|
||||
const mockPublicDomain = "http://customdomain.com";
|
||||
|
||||
const mockParams = {
|
||||
environmentId: mockEnvironmentId,
|
||||
@@ -179,7 +179,7 @@ describe("ResponsesPage", () => {
|
||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -205,7 +205,7 @@ describe("ResponsesPage", () => {
|
||||
survey: mockSurvey,
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
surveyDomain: mockSurveyDomain,
|
||||
publicDomain: mockPublicDomain,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
@@ -224,7 +224,7 @@ describe("ResponsesPage", () => {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
webAppUrl: "http://localhost:3000",
|
||||
publicDomain: mockPublicDomain,
|
||||
environmentTags: mockTags,
|
||||
user: mockUser,
|
||||
responsesPerPage: 10,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
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 { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
@@ -37,7 +37,7 @@ const Page = async (props) => {
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const surveyDomain = getSurveyDomain();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -49,7 +49,7 @@ const Page = async (props) => {
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
}>
|
||||
@@ -59,7 +59,7 @@ const Page = async (props) => {
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
|
||||
const defaultProps = {
|
||||
survey: mockSurveyWeb,
|
||||
surveyDomain: "test.com",
|
||||
publicDomain: "https://public-domain.com",
|
||||
open: true,
|
||||
modalView: "start" as "start" | "embed" | "panel",
|
||||
setOpen: mockSetOpen,
|
||||
@@ -158,7 +158,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockEmbedViewComponent.mockImplementation(
|
||||
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, surveyDomain, locale }) => (
|
||||
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
||||
<div>
|
||||
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
|
||||
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
|
||||
@@ -166,7 +166,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
<div data-testid="embedview-survey-id">{survey.id}</div>
|
||||
<div data-testid="embedview-email">{email}</div>
|
||||
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
|
||||
<div data-testid="embedview-surveyDomain">{surveyDomain}</div>
|
||||
<div data-testid="embedview-publicDomain">{publicDomain}</div>
|
||||
<div data-testid="embedview-locale">{locale}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
survey: TSurvey;
|
||||
surveyDomain: string;
|
||||
publicDomain: string;
|
||||
open: boolean;
|
||||
modalView: "start" | "embed" | "panel";
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -33,7 +33,7 @@ interface ShareEmbedSurveyProps {
|
||||
|
||||
export const ShareEmbedSurvey = ({
|
||||
survey,
|
||||
surveyDomain,
|
||||
publicDomain,
|
||||
open,
|
||||
modalView,
|
||||
setOpen,
|
||||
@@ -66,16 +66,16 @@ export const ShareEmbedSurvey = ({
|
||||
useEffect(() => {
|
||||
const fetchSurveyUrl = async () => {
|
||||
try {
|
||||
const url = await getSurveyUrl(survey, surveyDomain, "default");
|
||||
const url = await getSurveyUrl(survey, publicDomain, "default");
|
||||
setSurveyUrl(url);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch survey URL:", error);
|
||||
// Fallback to a default URL if fetching fails
|
||||
setSurveyUrl(`${surveyDomain}/s/${survey.id}`);
|
||||
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
|
||||
}
|
||||
};
|
||||
fetchSurveyUrl();
|
||||
}, [survey, surveyDomain]);
|
||||
}, [survey, publicDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.type !== "link") {
|
||||
@@ -120,7 +120,7 @@ export const ShareEmbedSurvey = ({
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
@@ -174,7 +174,7 @@ export const ShareEmbedSurvey = ({
|
||||
survey={survey}
|
||||
email={email}
|
||||
surveyUrl={surveyUrl}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
|
||||
@@ -104,13 +104,15 @@ describe("SummaryDropOffs", () => {
|
||||
|
||||
// Check drop-off counts and percentages
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
expect(screen.getByText("(20%)")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
|
||||
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
|
||||
|
||||
// Check percentage values
|
||||
const percentageElements = screen.getAllByText(/\d+%/);
|
||||
expect(percentageElements).toHaveLength(3);
|
||||
expect(percentageElements[0]).toHaveTextContent("20%");
|
||||
expect(percentageElements[1]).toHaveTextContent("19%");
|
||||
expect(percentageElements[2]).toHaveTextContent("15%");
|
||||
});
|
||||
|
||||
test("renders empty state when dropOff array is empty", () => {
|
||||
|
||||
@@ -23,9 +23,9 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="grid min-h-10 grid-cols-6 items-center rounded-t-xl border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||
<div className="col-span-3 px-4 md:px-6">{t("common.questions")}</div>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -37,14 +37,16 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="px-4 text-center md:px-6">{t("environments.surveys.summary.impressions")}</div>
|
||||
<div className="pr-6 text-center md:pl-6">{t("environments.surveys.summary.drop_offs")}</div>
|
||||
<div className="px-4 text-right md:px-6">{t("environments.surveys.summary.impressions")}</div>
|
||||
<div className="px-4 text-right md:mr-1 md:pl-6 md:pr-6">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
</div>
|
||||
</div>
|
||||
{dropOff.map((quesDropOff) => (
|
||||
<div
|
||||
key={quesDropOff.questionId}
|
||||
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="col-span-3 flex gap-3 pl-4 md:pl-6">
|
||||
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
||||
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
||||
{getIcon(quesDropOff.questionType)}
|
||||
<p>
|
||||
{formatTextWithSlashes(
|
||||
@@ -57,17 +59,21 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
"default"
|
||||
)["default"],
|
||||
"@",
|
||||
["text-lg"]
|
||||
["text-sm"]
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
|
||||
<div className="pl-6 text-center md:px-6">
|
||||
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
|
||||
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
|
||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
||||
{quesDropOff.impressions}
|
||||
</div>
|
||||
<div className="px-4 py-2 text-right md:px-6">
|
||||
<span className="mr-1 inline-block w-fit rounded-xl bg-slate-100 px-2 py-1 text-left text-xs">
|
||||
{Math.round(quesDropOff.dropOffPercentage)}%
|
||||
</span>
|
||||
<span className="mr-1 font-mono font-medium">{quesDropOff.dropOffCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -36,7 +36,7 @@ interface SummaryPageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
@@ -46,7 +46,7 @@ export const SummaryPage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
locale,
|
||||
isReadOnly,
|
||||
initialSurveySummary,
|
||||
@@ -133,7 +133,7 @@ export const SummaryPage = ({
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && (
|
||||
<ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} />
|
||||
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
|
||||
)}
|
||||
</div>
|
||||
<ScrollToTop containerId="mainContent" />
|
||||
|
||||
@@ -21,6 +21,8 @@ vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockPublicDomain = "https://public-domain.com";
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
@@ -49,6 +51,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
REDIS_URL: "mock-url",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
// Create a spy for refreshSingleUseId so we can override it in tests
|
||||
const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
|
||||
|
||||
@@ -72,7 +80,7 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
// Mock copySurveyLink to return a predictable string
|
||||
vi.mock("@/modules/survey/lib/client-utils", () => ({
|
||||
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
|
||||
copySurveyLink: vi.fn((url: string, suId: string) => `${url}?suId=${suId}`),
|
||||
}));
|
||||
|
||||
// Mock the copy survey action
|
||||
@@ -103,6 +111,10 @@ vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn(() => mockPublicDomain),
|
||||
}));
|
||||
|
||||
vi.spyOn(toast, "success");
|
||||
vi.spyOn(toast, "error");
|
||||
|
||||
@@ -123,7 +135,6 @@ const dummySurvey = {
|
||||
} as unknown as TSurvey;
|
||||
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
|
||||
const dummyUser = { id: "user123", name: "Test User" } as TUser;
|
||||
const surveyDomain = "https://surveys.test.formbricks.com";
|
||||
|
||||
describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
afterEach(() => {
|
||||
@@ -136,7 +147,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
@@ -147,9 +158,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
|
||||
expect(writeTextMock).toHaveBeenCalledWith(
|
||||
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
|
||||
);
|
||||
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
});
|
||||
@@ -161,7 +170,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
@@ -194,7 +203,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
@@ -215,7 +224,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={0}
|
||||
/>
|
||||
@@ -237,7 +246,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={true}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
@@ -266,7 +275,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
@@ -309,7 +318,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
@@ -335,7 +344,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
@@ -369,7 +378,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SurveyAnalysisCTAProps {
|
||||
environment: TEnvironment;
|
||||
isReadOnly: boolean;
|
||||
user: TUser;
|
||||
surveyDomain: string;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const SurveyAnalysisCTA = ({
|
||||
environment,
|
||||
isReadOnly,
|
||||
user,
|
||||
surveyDomain,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
@@ -56,7 +56,7 @@ export const SurveyAnalysisCTA = ({
|
||||
dropdown: false,
|
||||
});
|
||||
|
||||
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
|
||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -202,7 +202,7 @@ export const SurveyAnalysisCTA = ({
|
||||
<ShareEmbedSurvey
|
||||
key={key}
|
||||
survey={survey}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
open={modalState[key as keyof ModalState]}
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
|
||||
@@ -64,7 +64,7 @@ const defaultProps = {
|
||||
survey: mockSurveyLink,
|
||||
email: "test@example.com",
|
||||
surveyUrl: "http://example.com/survey1",
|
||||
surveyDomain: "http://example.com",
|
||||
publicDomain: "http://example.com",
|
||||
setSurveyUrl: vi.fn(),
|
||||
locale: "en" as any,
|
||||
disableBack: false,
|
||||
|
||||
@@ -20,7 +20,7 @@ interface EmbedViewProps {
|
||||
survey: any;
|
||||
email: string;
|
||||
surveyUrl: string;
|
||||
surveyDomain: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export const EmbedView = ({
|
||||
survey,
|
||||
email,
|
||||
surveyUrl,
|
||||
surveyDomain,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: EmbedViewProps) => {
|
||||
@@ -83,7 +83,7 @@ export const EmbedView = ({
|
||||
<LinkTab
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -6,12 +6,12 @@ import { LinkTab } from "./LinkTab";
|
||||
|
||||
// Mock ShareSurveyLink
|
||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
||||
ShareSurveyLink: vi.fn(({ survey, surveyUrl, surveyDomain, locale }) => (
|
||||
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
|
||||
<div data-testid="share-survey-link">
|
||||
Mocked ShareSurveyLink
|
||||
<span data-testid="survey-id">{survey.id}</span>
|
||||
<span data-testid="survey-url">{surveyUrl}</span>
|
||||
<span data-testid="survey-domain">{surveyDomain}</span>
|
||||
<span data-testid="public-domain">{publicDomain}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)),
|
||||
@@ -49,7 +49,7 @@ const mockSurvey: TSurvey = {
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
|
||||
const mockSurveyDomain = "https://app.formbricks.com";
|
||||
const mockPublicDomain = "https://app.formbricks.com";
|
||||
const mockSetSurveyUrl = vi.fn();
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("LinkTab", () => {
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
surveyDomain={mockSurveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
@@ -97,7 +97,7 @@ describe("LinkTab", () => {
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
surveyDomain={mockSurveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
@@ -105,7 +105,7 @@ describe("LinkTab", () => {
|
||||
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
|
||||
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
|
||||
expect(screen.getByTestId("survey-domain")).toHaveTextContent(mockSurveyDomain);
|
||||
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("LinkTab", () => {
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
surveyDomain={mockSurveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
@@ -129,7 +129,7 @@ describe("LinkTab", () => {
|
||||
<LinkTab
|
||||
survey={mockSurvey}
|
||||
surveyUrl={mockSurveyUrl}
|
||||
surveyDomain={mockSurveyDomain}
|
||||
publicDomain={mockPublicDomain}
|
||||
setSurveyUrl={mockSetSurveyUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
|
||||
@@ -9,12 +9,12 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
interface LinkTabProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
surveyDomain: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
||||
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const docsLinks = [
|
||||
@@ -44,7 +44,7 @@ export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
surveyUrl={surveyUrl}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -35,7 +34,16 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getSurveyUrl");
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn().mockReturnValue("https://public-domain.com"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service");
|
||||
vi.mock("@/lib/survey/service");
|
||||
vi.mock("@/lib/utils/styling");
|
||||
@@ -121,7 +129,7 @@ const mockComputedStyling = {
|
||||
thankYouCardIconBgColor: "#DDDDDD",
|
||||
} as any;
|
||||
|
||||
const mockSurveyDomain = "https://app.formbricks.com";
|
||||
const mockPublicDomain = "https://app.formbricks.com";
|
||||
const mockRawHtml = `${doctype}<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
|
||||
const mockCleanedHtml = `<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
|
||||
|
||||
@@ -136,7 +144,7 @@ describe("getEmailTemplateHtml", () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
||||
vi.mocked(getStyling).mockReturnValue(mockComputedStyling);
|
||||
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml);
|
||||
});
|
||||
|
||||
@@ -147,8 +155,8 @@ describe("getEmailTemplateHtml", () => {
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId);
|
||||
expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey);
|
||||
expect(getSurveyDomain).toHaveBeenCalledTimes(1);
|
||||
const expectedSurveyUrl = `${mockSurveyDomain}/s/${mockSurvey.id}`;
|
||||
expect(getPublicDomain).toHaveBeenCalledTimes(1);
|
||||
const expectedSurveyUrl = `${mockPublicDomain}/s/${mockSurvey.id}`;
|
||||
expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith(
|
||||
mockSurvey,
|
||||
expectedSurveyUrl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
|
||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||
const doctype =
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -38,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
RESPONSES_PER_PAGE: 10,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
@@ -64,8 +63,8 @@ vi.mock(
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/lib/getSurveyUrl", () => ({
|
||||
getSurveyDomain: vi.fn(),
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/service", () => ({
|
||||
@@ -211,7 +210,7 @@ describe("SurveyPage", () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
|
||||
vi.mocked(getPublicDomain).mockReturnValue("http://localhost:3000");
|
||||
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
|
||||
vi.mocked(notFound).mockClear();
|
||||
});
|
||||
@@ -235,7 +234,7 @@ describe("SurveyPage", () => {
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
|
||||
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
|
||||
expect(vi.mocked(getPublicDomain)).toHaveBeenCalled();
|
||||
|
||||
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -250,7 +249,7 @@ describe("SurveyPage", () => {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
publicDomain: "http://localhost:3000",
|
||||
isReadOnly: false,
|
||||
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
||||
initialSurveySummary: mockSurveySummary,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -40,7 +40,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
const surveyDomain = getSurveyDomain();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -52,7 +52,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
/>
|
||||
}>
|
||||
@@ -62,7 +62,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("ResultsShareButton", () => {
|
||||
|
||||
test("renders initial state and fetches sharing key (no existing key)", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
@@ -150,7 +150,7 @@ describe("ResultsShareButton", () => {
|
||||
|
||||
test("handles copy private link to clipboard", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
@@ -166,7 +166,9 @@ describe("ResultsShareButton", () => {
|
||||
test("handles copy public link to clipboard", async () => {
|
||||
const shareKey = "publicShareKey";
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
|
||||
render(
|
||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
@@ -184,7 +186,7 @@ describe("ResultsShareButton", () => {
|
||||
test("handles publish to web successfully", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
|
||||
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
@@ -210,7 +212,9 @@ describe("ResultsShareButton", () => {
|
||||
const shareKey = "toUnpublishKey";
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
|
||||
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
|
||||
render(
|
||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
@@ -234,7 +238,7 @@ describe("ResultsShareButton", () => {
|
||||
|
||||
test("opens and closes ShareSurveyResults modal", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
|
||||
@@ -21,10 +21,10 @@ import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurvey
|
||||
|
||||
interface ResultsShareButtonProps {
|
||||
survey: TSurvey;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProps) => {
|
||||
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
|
||||
const handlePublish = async () => {
|
||||
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
|
||||
if (resultShareKeyResponse?.data) {
|
||||
setSurveyUrl(webAppUrl + "/share/" + resultShareKeyResponse.data);
|
||||
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
|
||||
setShowPublishModal(true);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
|
||||
@@ -58,13 +58,13 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
|
||||
const fetchSharingKey = async () => {
|
||||
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
|
||||
if (resultShareUrlResponse?.data) {
|
||||
setSurveyUrl(webAppUrl + "/share/" + resultShareUrlResponse.data);
|
||||
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
|
||||
setShowPublishModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSharingKey();
|
||||
}, [survey.id, webAppUrl]);
|
||||
}, [survey.id, publicDomain]);
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
@@ -14,41 +14,64 @@ describe("ClientEnvironmentRedirect", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should redirect to the provided environment ID when no last environment exists", () => {
|
||||
test("should redirect to the first environment ID when no last environment exists", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
render(<ClientEnvironmentRedirect userEnvironments={["test-env-id"]} />);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
|
||||
});
|
||||
|
||||
test("should redirect to the last environment ID when it exists in localStorage", () => {
|
||||
test("should redirect to the last environment ID when it exists in localStorage and is valid", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage with a last environment ID
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue("last-env-id"),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
render(<ClientEnvironmentRedirect userEnvironments={["last-env-id", "other-env-id"]} />);
|
||||
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
|
||||
});
|
||||
|
||||
test("should clear invalid environment ID and redirect to default when stored ID is not in user environments", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage with an invalid environment ID
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue("invalid-env-id"),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect userEnvironments={["valid-env-1", "valid-env-2"]} />);
|
||||
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/valid-env-1");
|
||||
});
|
||||
|
||||
test("should update redirect when environment ID prop changes", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
@@ -56,19 +79,20 @@ describe("ClientEnvironmentRedirect", () => {
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
|
||||
const { rerender } = render(<ClientEnvironmentRedirect userEnvironments={["initial-env-id"]} />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
|
||||
|
||||
// Clear mock calls
|
||||
mockPush.mockClear();
|
||||
|
||||
// Rerender with new environment ID
|
||||
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
|
||||
rerender(<ClientEnvironmentRedirect userEnvironments={["new-env-id"]} />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,22 +5,23 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface ClientEnvironmentRedirectProps {
|
||||
environmentId: string;
|
||||
userEnvironments: string[];
|
||||
}
|
||||
|
||||
const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => {
|
||||
const ClientEnvironmentRedirect = ({ userEnvironments }: ClientEnvironmentRedirectProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
if (lastEnvironmentId) {
|
||||
// Redirect to the last environment the user was in
|
||||
if (lastEnvironmentId && userEnvironments.includes(lastEnvironmentId)) {
|
||||
router.push(`/environments/${lastEnvironmentId}`);
|
||||
} else {
|
||||
router.push(`/environments/${environmentId}`);
|
||||
// If the last environmentId is not valid, remove it from localStorage and redirect to the provided environmentId
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
router.push(`/environments/${userEnvironments[0]}`);
|
||||
}
|
||||
}, [environmentId, router]);
|
||||
}, [userEnvironments, router]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import packageJson from "@/package.json";
|
||||
import { headers } from "next/headers";
|
||||
@@ -13,6 +14,10 @@ export const POST = async () => {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (env.TELEMETRY_DISABLED === "1") {
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
const [surveyCount, responseCount, userCount] = await Promise.all([
|
||||
prisma.survey.count(),
|
||||
prisma.response.count(),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { checkForRequiredFields } from "./utils";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Session } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { checkForRequiredFields } from "./utils";
|
||||
import { checkAuth } from "./utils";
|
||||
|
||||
// Create mock response objects
|
||||
@@ -16,189 +16,197 @@ const mockNotAuthenticatedResponse = new Response("Not authenticated", { status:
|
||||
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
|
||||
hasPermission: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
badRequestResponse: vi.fn(() => mockBadRequestResponse),
|
||||
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
|
||||
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
|
||||
},
|
||||
responses: {
|
||||
badRequestResponse: vi.fn(() => mockBadRequestResponse),
|
||||
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
|
||||
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("checkForRequiredFields", () => {
|
||||
test("should return undefined when all required fields are present", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
test("should return undefined when all required fields are present", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return bad request response when environmentId is missing", () => {
|
||||
const result = checkForRequiredFields("", "image/png", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
test("should return bad request response when environmentId is missing", () => {
|
||||
const result = checkForRequiredFields("", "image/png", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when fileType is missing", () => {
|
||||
const result = checkForRequiredFields("env-123", "", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
test("should return bad request response when fileType is missing", () => {
|
||||
const result = checkForRequiredFields("env-123", "", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when encodedFileName is missing", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", "");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
test("should return bad request response when encodedFileName is missing", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", "");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when environmentId is undefined", () => {
|
||||
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
test("should return bad request response when environmentId is undefined", () => {
|
||||
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when fileType is undefined", () => {
|
||||
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
test("should return bad request response when fileType is undefined", () => {
|
||||
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when encodedFileName is undefined", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
test("should return bad request response when encodedFileName is undefined", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAuth", () => {
|
||||
const environmentId = "env-123";
|
||||
const mockRequest = new NextRequest("http://localhost:3000/api/test");
|
||||
const environmentId = "env-123";
|
||||
const mockRequest = new NextRequest("http://localhost:3000/api/test");
|
||||
|
||||
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
});
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
});
|
||||
|
||||
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-123",
|
||||
permission: "read",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
},
|
||||
};
|
||||
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-123",
|
||||
permission: "read",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(
|
||||
mockAuthentication.environmentPermissions,
|
||||
environmentId,
|
||||
"POST"
|
||||
);
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
|
||||
test("returns undefined when no session and authentication has POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-123",
|
||||
permission: "write",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
},
|
||||
};
|
||||
test("returns undefined when no session and authentication has POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-123",
|
||||
permission: "write",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(
|
||||
mockAuthentication.environmentPermissions,
|
||||
environmentId,
|
||||
"POST"
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
|
||||
test("returns undefined when session exists and user has environment access", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
test("returns undefined when session exists and user has environment access", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not call authenticateRequest when session exists", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
test("does not call authenticateRequest when session exists", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
await checkAuth(mockSession, environmentId, mockRequest);
|
||||
await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).not.toHaveBeenCalled();
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
});
|
||||
});
|
||||
expect(authenticateRequest).not.toHaveBeenCalled();
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Session } from "next-auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Session } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const checkForRequiredFields = (
|
||||
environmentId: string,
|
||||
fileType: string,
|
||||
encodedFileName: string
|
||||
): Response | undefined => {
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
export const checkForRequiredFields = (environmentId: string, fileType: string, encodedFileName: string): Response | undefined => {
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!encodedFileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
if (!encodedFileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
};
|
||||
|
||||
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
|
||||
if (!session) {
|
||||
//check whether its using API key
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
if (!session) {
|
||||
//check whether its using API key
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else {
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// headers -> "Content-Type" should be present and set to a valid MIME type
|
||||
// body -> should be a valid file object (buffer)
|
||||
// method -> PUT (to be the same as the signedUrl method)
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
@@ -10,7 +11,6 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
|
||||
export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -5,8 +6,6 @@ import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
|
||||
|
||||
// api endpoint for uploading public files
|
||||
// uploaded files will be public, anyone can access the file
|
||||
@@ -14,7 +13,6 @@ import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/stora
|
||||
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
|
||||
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
|
||||
|
||||
|
||||
export const POST = async (request: NextRequest): Promise<Response> => {
|
||||
let storageInput;
|
||||
|
||||
@@ -34,7 +32,6 @@ export const POST = async (request: NextRequest): Promise<Response> => {
|
||||
const authResponse = await checkAuth(session, environmentId, request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
|
||||
// Perform server-side file validation first to block dangerous file types
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
@@ -42,10 +42,10 @@ export const GET = async (
|
||||
|
||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||
|
||||
const surveyDomain = getSurveyDomain();
|
||||
const publicDomain = getPublicDomain();
|
||||
// map single use ids to survey links
|
||||
const surveyLinks = singleUseIds.map(
|
||||
(singleUseId) => `${surveyDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||
);
|
||||
|
||||
return responses.successResponse(surveyLinks);
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
shuffleOption: "none",
|
||||
required: true,
|
||||
required: false,
|
||||
});
|
||||
expect(question.choices.length).toBe(3);
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
|
||||
inputType: "text",
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
|
||||
range: 5,
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
|
||||
headline: { default: "NPS Question" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
|
||||
label: { default: "I agree to terms" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
|
||||
headline: { default: "CTA Question" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: true,
|
||||
required: false,
|
||||
buttonExternal: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
|
||||
@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
logic,
|
||||
};
|
||||
};
|
||||
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
logic,
|
||||
charLimit: {
|
||||
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
|
||||
range,
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
label: createI18nString(label, []),
|
||||
logic,
|
||||
};
|
||||
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||
required: required ?? true,
|
||||
required: required ?? false,
|
||||
buttonExternal,
|
||||
buttonUrl,
|
||||
logic,
|
||||
|
||||
@@ -3006,12 +3006,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
|
||||
t("templates.understand_low_engagement_question_1_choice_4"),
|
||||
t("templates.understand_low_engagement_question_1_choice_5"),
|
||||
],
|
||||
choiceIds: [
|
||||
reusableOptionIds[0],
|
||||
reusableOptionIds[1],
|
||||
reusableOptionIds[2],
|
||||
reusableOptionIds[3],
|
||||
],
|
||||
choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2], reusableOptionIds[3]],
|
||||
headline: t("templates.understand_low_engagement_question_1_headline"),
|
||||
required: true,
|
||||
containsOther: true,
|
||||
|
||||
85
apps/web/app/middleware/domain-utils.test.ts
Normal file
85
apps/web/app/middleware/domain-utils.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getPublicDomainHost, isPublicDomainConfigured, isRequestFromPublicDomain } from "./domain-utils";
|
||||
|
||||
// Mock the env module
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
get PUBLIC_URL() {
|
||||
return process.env.PUBLIC_URL || "";
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Domain Utils", () => {
|
||||
beforeEach(() => {
|
||||
process.env.PUBLIC_URL = "";
|
||||
});
|
||||
|
||||
describe("getPublicDomain", () => {
|
||||
test("should return null when PUBLIC_URL is empty", () => {
|
||||
expect(getPublicDomainHost()).toBeNull();
|
||||
});
|
||||
|
||||
test("should return the host from a valid PUBLIC_URL", () => {
|
||||
process.env.PUBLIC_URL = "https://example.com";
|
||||
expect(getPublicDomainHost()).toBe("example.com");
|
||||
});
|
||||
|
||||
test("should handle URLs with paths", () => {
|
||||
process.env.PUBLIC_URL = "https://example.com/path";
|
||||
expect(getPublicDomainHost()).toBe("example.com");
|
||||
});
|
||||
|
||||
test("should handle URLs with ports", () => {
|
||||
process.env.PUBLIC_URL = "https://example.com:3000";
|
||||
expect(getPublicDomainHost()).toBe("example.com:3000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublicDomainConfigured", () => {
|
||||
test("should return false when PUBLIC_URL is empty", () => {
|
||||
process.env.PUBLIC_URL = "";
|
||||
expect(isPublicDomainConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true when PUBLIC_URL is valid", () => {
|
||||
process.env.PUBLIC_URL = "https://example.com";
|
||||
expect(isPublicDomainConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRequestFromPublicDomain", () => {
|
||||
test("should return false when public domain is not configured", () => {
|
||||
process.env.PUBLIC_URL = "";
|
||||
const request = new NextRequest("https://example.com");
|
||||
expect(isRequestFromPublicDomain(request)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when host doesn't match public domain", () => {
|
||||
process.env.PUBLIC_URL = "https://example.com";
|
||||
const request = new NextRequest("https://different-domain.com");
|
||||
expect(isRequestFromPublicDomain(request)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true when host matches public domain", () => {
|
||||
process.env.PUBLIC_URL = "https://example.com";
|
||||
const request = new NextRequest("https://example.com", {
|
||||
headers: {
|
||||
host: "example.com",
|
||||
},
|
||||
});
|
||||
expect(isRequestFromPublicDomain(request)).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle domains with ports", () => {
|
||||
process.env.PUBLIC_URL = "https://example.com:3000";
|
||||
const request = new NextRequest("https://example.com:3000", {
|
||||
headers: {
|
||||
host: "example.com:3000",
|
||||
},
|
||||
});
|
||||
expect(isRequestFromPublicDomain(request)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
31
apps/web/app/middleware/domain-utils.ts
Normal file
31
apps/web/app/middleware/domain-utils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Get the public domain from PUBLIC_URL environment variable
|
||||
*/
|
||||
export const getPublicDomainHost = (): string | null => {
|
||||
const PUBLIC_URL = env.PUBLIC_URL;
|
||||
if (!PUBLIC_URL) return null;
|
||||
|
||||
return new URL(PUBLIC_URL).host;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if PUBLIC_URL is configured (has a valid public domain)
|
||||
*/
|
||||
export const isPublicDomainConfigured = (): boolean => {
|
||||
return getPublicDomainHost() !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current request is coming from the public domain
|
||||
*/
|
||||
export const isRequestFromPublicDomain = (request: NextRequest): boolean => {
|
||||
const host = request.headers.get("host");
|
||||
const publicDomainHost = getPublicDomainHost();
|
||||
|
||||
if (!publicDomainHost) return false;
|
||||
|
||||
return host === publicDomainHost;
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
isAdminDomainRoute,
|
||||
isAuthProtectedRoute,
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isPublicDomainRoute,
|
||||
isRouteAllowedForDomain,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
@@ -69,6 +72,9 @@ describe("endpoint-validator", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/api/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/auth/login")).toBe(false);
|
||||
|
||||
// exception for open graph image generation route, it should not be rate limited
|
||||
expect(isClientSideApiRoute("/api/v1/client/og")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,4 +142,138 @@ describe("endpoint-validator", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublicDomainRoute", () => {
|
||||
test("should return true for health endpoint", () => {
|
||||
expect(isPublicDomainRoute("/health")).toBe(true);
|
||||
});
|
||||
|
||||
// Static assets are not handled by domain routing - middleware doesn't run on them
|
||||
|
||||
test("should return true for survey routes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey-id-with-dashes")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for contact survey routes", () => {
|
||||
expect(isPublicDomainRoute("/c/jwt-token")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/very-long-jwt-token-123")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for share routes", () => {
|
||||
expect(isPublicDomainRoute("/share/abc123/summary")).toBe(true);
|
||||
expect(isPublicDomainRoute("/share/xyz789/responses")).toBe(true);
|
||||
expect(isPublicDomainRoute("/share/anything")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for admin-only routes", () => {
|
||||
expect(isPublicDomainRoute("/")).toBe(false);
|
||||
expect(isPublicDomainRoute("/environments/123")).toBe(false);
|
||||
expect(isPublicDomainRoute("/auth/login")).toBe(false);
|
||||
expect(isPublicDomainRoute("/setup/organization")).toBe(false);
|
||||
expect(isPublicDomainRoute("/organizations/123")).toBe(false);
|
||||
expect(isPublicDomainRoute("/product/settings")).toBe(false);
|
||||
expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false);
|
||||
expect(isPublicDomainRoute("/api/v2/management/surveys")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdminDomainRoute", () => {
|
||||
test("should return true for health endpoint (backward compatibility)", () => {
|
||||
expect(isAdminDomainRoute("/health")).toBe(true);
|
||||
expect(isAdminDomainRoute("/health")).toBe(true);
|
||||
});
|
||||
|
||||
// Static assets are not handled by domain routing - middleware doesn't run on them
|
||||
|
||||
test("should return true for admin routes", () => {
|
||||
expect(isAdminDomainRoute("/")).toBe(true);
|
||||
expect(isAdminDomainRoute("/environments/123")).toBe(true);
|
||||
expect(isAdminDomainRoute("/environments/123/surveys")).toBe(true);
|
||||
expect(isAdminDomainRoute("/auth/login")).toBe(true);
|
||||
expect(isAdminDomainRoute("/auth/signup")).toBe(true);
|
||||
expect(isAdminDomainRoute("/setup/organization")).toBe(true);
|
||||
expect(isAdminDomainRoute("/setup/team")).toBe(true);
|
||||
expect(isAdminDomainRoute("/organizations/123")).toBe(true);
|
||||
expect(isAdminDomainRoute("/organizations/123/settings")).toBe(true);
|
||||
expect(isAdminDomainRoute("/product/settings")).toBe(true);
|
||||
expect(isAdminDomainRoute("/product/features")).toBe(true);
|
||||
expect(isAdminDomainRoute("/api/v1/management/users")).toBe(true);
|
||||
expect(isAdminDomainRoute("/api/v2/management/surveys")).toBe(true);
|
||||
expect(isAdminDomainRoute("/pipeline/jobs")).toBe(true);
|
||||
expect(isAdminDomainRoute("/cron/tasks")).toBe(true);
|
||||
expect(isAdminDomainRoute("/random/route")).toBe(true);
|
||||
expect(isAdminDomainRoute("/s/survey123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRouteAllowedForDomain", () => {
|
||||
test("should allow public routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/share/abc/summary", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||
// Static assets not tested - middleware doesn't run on them
|
||||
});
|
||||
|
||||
test("should block admin routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
|
||||
});
|
||||
|
||||
test("should block public routes on admin domain when PUBLIC_URL is configured", () => {
|
||||
// Admin routes should be allowed
|
||||
expect(isRouteAllowedForDomain("/", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
|
||||
|
||||
// Public routes should be blocked on admin domain
|
||||
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/share/abc/summary", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should handle empty paths", () => {
|
||||
expect(isPublicDomainRoute("")).toBe(false);
|
||||
expect(isAdminDomainRoute("")).toBe(true);
|
||||
expect(isAdminDomainRoute("")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle paths with query parameters", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123?param=value")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle paths with fragments", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle nested survey routes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle nested client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
getAllPubliclyAccessibleRoutePatterns,
|
||||
getPublicDomainRoutePatterns,
|
||||
matchesAnyPattern,
|
||||
} from "./route-config";
|
||||
|
||||
export const isLoginRoute = (url: string) =>
|
||||
url === "/api/auth/callback/credentials" || url === "/auth/login";
|
||||
|
||||
@@ -8,6 +14,9 @@ export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
|
||||
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
|
||||
|
||||
export const isClientSideApiRoute = (url: string): boolean => {
|
||||
// Open Graph image generation route is a client side API route but it should not be rate limited
|
||||
if (url.includes("/api/v1/client/og")) return false;
|
||||
|
||||
if (url.includes("/api/v1/js/actions")) return true;
|
||||
if (url.includes("/api/v1/client/storage")) return true;
|
||||
const regex = /^\/api\/v\d+\/client\//;
|
||||
@@ -38,3 +47,39 @@ export const isSyncWithUserIdentificationEndpoint = (
|
||||
const match = url.match(regex);
|
||||
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the route should be accessible on the public domain (PUBLIC_URL)
|
||||
* Uses whitelist approach - only explicitly allowed routes are accessible
|
||||
*/
|
||||
export const isPublicDomainRoute = (url: string): boolean => {
|
||||
const publicRoutePatterns = getAllPubliclyAccessibleRoutePatterns();
|
||||
return matchesAnyPattern(url, publicRoutePatterns);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the route should be accessible on the admin domain (WEBAPP_URL)
|
||||
* When PUBLIC_URL is configured, admin domain should only allow admin-specific routes + health
|
||||
*/
|
||||
export const isAdminDomainRoute = (url: string): boolean => {
|
||||
const publicOnlyRoutePatterns = getPublicDomainRoutePatterns();
|
||||
const isPublicRoute = matchesAnyPattern(url, publicOnlyRoutePatterns);
|
||||
|
||||
if (isPublicRoute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For non-public routes, allow them (includes known admin routes and unknown routes like pipeline, cron)
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if a request should be allowed based on domain and route
|
||||
*/
|
||||
export const isRouteAllowedForDomain = (url: string, isPublicDomain: boolean): boolean => {
|
||||
if (isPublicDomain) {
|
||||
return isPublicDomainRoute(url);
|
||||
}
|
||||
|
||||
return isAdminDomainRoute(url);
|
||||
};
|
||||
|
||||
54
apps/web/app/middleware/route-config.ts
Normal file
54
apps/web/app/middleware/route-config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Routes that should be accessible on the public domain (PUBLIC_URL)
|
||||
* Uses whitelist approach - only these routes are allowed on public domain
|
||||
*/
|
||||
const PUBLIC_ROUTES = {
|
||||
// Survey routes
|
||||
SURVEY_ROUTES: [
|
||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||
],
|
||||
|
||||
// API routes accessible from public domain
|
||||
API_ROUTES: [
|
||||
/^\/api\/v[12]\/client\//, // /api/v1/client/** and /api/v2/client/**
|
||||
],
|
||||
|
||||
// Share routes
|
||||
SHARE_ROUTES: [
|
||||
/^\/share\//, // /share/** - shared survey results
|
||||
],
|
||||
} as const;
|
||||
|
||||
const COMMON_ROUTES = {
|
||||
HEALTH_ROUTES: [/^\/health$/], // /health endpoint
|
||||
PUBLIC_STORAGE_ROUTES: [
|
||||
/^\/storage\/[^/]+\/public\//, // /storage/[environmentId]/public/** - public storage
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get public only route patterns as a flat array
|
||||
*/
|
||||
export const getPublicDomainRoutePatterns = (): RegExp[] => {
|
||||
return Object.values(PUBLIC_ROUTES).flat();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all public route patterns as a flat array
|
||||
*/
|
||||
export const getAllPubliclyAccessibleRoutePatterns = (): RegExp[] => {
|
||||
const routes = {
|
||||
...PUBLIC_ROUTES,
|
||||
...COMMON_ROUTES,
|
||||
};
|
||||
|
||||
return Object.values(routes).flat();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a URL matches any of the given route patterns
|
||||
*/
|
||||
export const matchesAnyPattern = (url: string, patterns: RegExp[]): boolean => {
|
||||
return patterns.some((pattern) => pattern.test(url));
|
||||
};
|
||||
@@ -3,12 +3,12 @@ import { cleanup } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getFirstEnvironmentIdByUserId: vi.fn(),
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProjectEnvironmentsByOrganizationIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/instance/service", () => ({
|
||||
@@ -48,8 +48,11 @@ vi.mock("@/modules/ui/components/client-logout", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/app/ClientEnvironmentRedirect", () => ({
|
||||
default: ({ environmentId }: { environmentId: string }) => (
|
||||
<div data-testid="client-environment-redirect">Environment ID: {environmentId}</div>
|
||||
default: ({ environmentId, userEnvironments }: { environmentId: string; userEnvironments?: string[] }) => (
|
||||
<div data-testid="client-environment-redirect">
|
||||
Environment ID: {environmentId}
|
||||
{userEnvironments && ` | User Environments: ${userEnvironments.join(", ")}`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -149,7 +152,7 @@ describe("Page", () => {
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { redirect } = await import("next/navigation");
|
||||
@@ -204,13 +207,23 @@ describe("Page", () => {
|
||||
role: "owner",
|
||||
};
|
||||
|
||||
const mockUserProjects = [
|
||||
{
|
||||
id: "test-project-id",
|
||||
name: "Test Project",
|
||||
environments: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
||||
mockUserProjects as unknown as TProject[]
|
||||
);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isManager: false,
|
||||
@@ -228,8 +241,8 @@ describe("Page", () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { redirect } = await import("next/navigation");
|
||||
@@ -284,13 +297,23 @@ describe("Page", () => {
|
||||
role: "member",
|
||||
};
|
||||
|
||||
const mockUserProjects = [
|
||||
{
|
||||
id: "test-project-id",
|
||||
name: "Test Project",
|
||||
environments: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
||||
mockUserProjects as unknown as TProject[]
|
||||
);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isManager: false,
|
||||
@@ -309,9 +332,9 @@ describe("Page", () => {
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { render } = await import("@testing-library/react");
|
||||
|
||||
const mockUser: TUser = {
|
||||
@@ -364,7 +387,43 @@ describe("Page", () => {
|
||||
role: "member",
|
||||
};
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockUserProjects = [
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "test-org-id",
|
||||
styling: { allowStyleOverwrite: true },
|
||||
recontactDays: 0,
|
||||
inAppSurveyBranding: false,
|
||||
linkSurveyBranding: false,
|
||||
config: { channel: "link" as const, industry: "saas" as const },
|
||||
placement: "bottomRight" as const,
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
languages: [],
|
||||
logo: null,
|
||||
environments: [
|
||||
{
|
||||
id: "test-env-id",
|
||||
type: "production" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
{
|
||||
id: "test-env-dev",
|
||||
type: "development" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({
|
||||
user: { id: "test-user-id" },
|
||||
@@ -372,8 +431,8 @@ describe("Page", () => {
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
@@ -385,7 +444,7 @@ describe("Page", () => {
|
||||
const { container } = render(result);
|
||||
|
||||
expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent(
|
||||
`Environment ID: ${mockEnvironmentId}`
|
||||
`User Environments: test-env-id, test-env-dev`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
|
||||
import { getFirstEnvironmentIdByUserId } from "@/lib/environment/service";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
@@ -34,16 +34,34 @@ const Page = async () => {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
let environmentId: string | null = null;
|
||||
environmentId = await getFirstEnvironmentIdByUserId(session.user.id);
|
||||
const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id));
|
||||
|
||||
// Flatten all environments from all projects across all organizations
|
||||
const allEnvironments = projectsByOrg.flatMap((project) => project.environments);
|
||||
|
||||
// Find first production environment and collect all other environment IDs in one pass
|
||||
const { firstProductionEnvironmentId, otherEnvironmentIds } = allEnvironments.reduce(
|
||||
(acc, env) => {
|
||||
if (env.type === "production" && !acc.firstProductionEnvironmentId) {
|
||||
acc.firstProductionEnvironmentId = env.id;
|
||||
} else {
|
||||
acc.otherEnvironmentIds.add(env.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ firstProductionEnvironmentId: null as string | null, otherEnvironmentIds: new Set<string>() }
|
||||
);
|
||||
|
||||
const userEnvironments = [...otherEnvironmentIds];
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
session.user.id,
|
||||
userOrganizations[0].id
|
||||
);
|
||||
|
||||
const { isManager, isOwner } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (!environmentId) {
|
||||
if (!firstProductionEnvironmentId) {
|
||||
if (isOwner || isManager) {
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`);
|
||||
} else {
|
||||
@@ -51,7 +69,10 @@ const Page = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
return <ClientEnvironmentRedirect environmentId={environmentId} />;
|
||||
// Put the first production environment at the front of the array
|
||||
const sortedUserEnvironments = [firstProductionEnvironmentId, ...userEnvironments];
|
||||
|
||||
return <ClientEnvironmentRedirect userEnvironments={sortedUserEnvironments} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
|
||||
|
||||
export default LinkSurveyNotFound;
|
||||
export default function NotFound() {
|
||||
return <LinkSurveyNotFound />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
@@ -46,6 +47,7 @@ const Page = async (props: ResponsesPageProps) => {
|
||||
}
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
@@ -57,7 +59,7 @@ const Page = async (props: ResponsesPageProps) => {
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={surveyId}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
environmentTags={tags}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={locale}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -50,6 +51,8 @@ const Page = async (props: SummaryPageProps) => {
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<PageContentWrapper className="w-full">
|
||||
@@ -60,7 +63,7 @@ const Page = async (props: SummaryPageProps) => {
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={survey.id}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
publicDomain={publicDomain}
|
||||
isReadOnly={true}
|
||||
locale={DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
ZIntegrationAirtableTokenSchema,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
|
||||
import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service";
|
||||
import { delay } from "../utils/promises";
|
||||
import { truncateText } from "../utils/strings";
|
||||
|
||||
export const getBases = async (key: string) => {
|
||||
@@ -99,7 +100,11 @@ export const getAirtableToken = async (environmentId: string) => {
|
||||
});
|
||||
|
||||
if (!newToken) {
|
||||
throw new Error("Failed to create new token");
|
||||
logger.error("Failed to fetch new Airtable token", {
|
||||
environmentId,
|
||||
airtableIntegration,
|
||||
});
|
||||
throw new Error("Failed to fetch new Airtable token");
|
||||
}
|
||||
|
||||
await createOrUpdateIntegration(environmentId, {
|
||||
@@ -116,9 +121,11 @@ export const getAirtableToken = async (environmentId: string) => {
|
||||
|
||||
return access_token;
|
||||
} catch (error) {
|
||||
await deleteIntegration(environmentId);
|
||||
|
||||
throw new Error("invalid token");
|
||||
logger.error("Failed to get Airtable token", {
|
||||
environmentId,
|
||||
error,
|
||||
});
|
||||
throw new Error("Failed to get Airtable token");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,6 +185,18 @@ const addField = async (
|
||||
return await req.json();
|
||||
};
|
||||
|
||||
const getExistingFields = async (key: TIntegrationAirtableCredential, baseId: string, tableId: string) => {
|
||||
const req = await tableFetcher(key, baseId);
|
||||
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
|
||||
const currentTable = tables.find((t) => t.id === tableId);
|
||||
|
||||
if (!currentTable) {
|
||||
throw new Error(`Table with ID ${tableId} not found`);
|
||||
}
|
||||
|
||||
return new Set(currentTable.fields.map((f) => f.name));
|
||||
};
|
||||
|
||||
export const writeData = async (
|
||||
key: TIntegrationAirtableCredential,
|
||||
configData: TIntegrationAirtableConfigData,
|
||||
@@ -186,6 +205,7 @@ export const writeData = async (
|
||||
const responses = values[0];
|
||||
const questions = values[1];
|
||||
|
||||
// 1) Build the record payload
|
||||
const data: Record<string, string> = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
data[questions[i]] =
|
||||
@@ -194,34 +214,73 @@ export const writeData = async (
|
||||
: responses[i];
|
||||
}
|
||||
|
||||
const req = await tableFetcher(key, configData.baseId);
|
||||
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
|
||||
// 2) Figure out which fields need creating
|
||||
const existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
|
||||
const fieldsToCreate = questions.filter((q) => !existingFields.has(q));
|
||||
|
||||
const currentTable = tables.find((table) => table.id === configData.tableId);
|
||||
if (currentTable) {
|
||||
const currentFields = new Set(currentTable.fields.map((field) => field.name));
|
||||
const fieldsToCreate = new Set<string>();
|
||||
for (const field of questions) {
|
||||
const hasField = currentFields.has(field);
|
||||
if (!hasField) {
|
||||
fieldsToCreate.add(field);
|
||||
// 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit
|
||||
if (fieldsToCreate.length > 0) {
|
||||
// Sequential processing with delays
|
||||
const DELAY_BETWEEN_REQUESTS = 250; // 250ms = 4 requests per second (staying under 5/sec limit)
|
||||
|
||||
for (let i = 0; i < fieldsToCreate.length; i++) {
|
||||
const fieldName = fieldsToCreate[i];
|
||||
|
||||
const createRes = await addField(key, configData.baseId, configData.tableId, {
|
||||
name: fieldName,
|
||||
type: "singleLineText",
|
||||
});
|
||||
|
||||
if (createRes?.error) {
|
||||
throw new Error(`Failed to create field "${fieldName}": ${JSON.stringify(createRes)}`);
|
||||
}
|
||||
|
||||
// Add delay between requests (except for the last one)
|
||||
if (i < fieldsToCreate.length - 1) {
|
||||
await delay(DELAY_BETWEEN_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldsToCreate.size > 0) {
|
||||
const createFieldPromise: Promise<any>[] = [];
|
||||
fieldsToCreate.forEach((fieldName) => {
|
||||
createFieldPromise.push(
|
||||
addField(key, configData.baseId, configData.tableId, {
|
||||
name: fieldName,
|
||||
type: "singleLineText",
|
||||
})
|
||||
);
|
||||
});
|
||||
// 4) Wait for the new fields to show up
|
||||
await waitForFieldsToExist(key, configData, fieldsToCreate);
|
||||
}
|
||||
|
||||
await Promise.all(createFieldPromise);
|
||||
// 5) Finally, add the records
|
||||
await addRecords(key, configData.baseId, configData.tableId, data);
|
||||
};
|
||||
|
||||
async function waitForFieldsToExist(
|
||||
key: TIntegrationAirtableCredential,
|
||||
configData: TIntegrationAirtableConfigData,
|
||||
fieldNames: string[],
|
||||
maxRetries = 5,
|
||||
intervalMs = 2000
|
||||
) {
|
||||
let existingFields: Set<string> = new Set(),
|
||||
missingFields: string[] = [];
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
|
||||
missingFields = fieldNames.filter((f) => !existingFields.has(f));
|
||||
|
||||
if (missingFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
logger.error(
|
||||
`Attempt ${attempt}/${maxRetries}: ${missingFields.length} field(s) still missing [${missingFields.join(
|
||||
", "
|
||||
)}], retrying in ${intervalMs / 1000}s…`
|
||||
);
|
||||
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
await addRecords(key, configData.baseId, configData.tableId, data);
|
||||
};
|
||||
throw new Error(
|
||||
`Timed out waiting for ${missingFields.length} field(s) [${missingFields.join(
|
||||
", "
|
||||
)}] to become available. Available fields: [${Array.from(existingFields).join(", ")}]`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ export const E2E_TESTING = env.E2E_TESTING === "1";
|
||||
export const WEBAPP_URL =
|
||||
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
|
||||
|
||||
export const SURVEY_URL = env.SURVEY_URL;
|
||||
|
||||
// encryption keys
|
||||
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||
|
||||
|
||||
@@ -85,7 +85,23 @@ export const env = createEnv({
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: z.enum(["1", "0"]).optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
SURVEY_URL: z.string().optional(),
|
||||
PUBLIC_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.host && parsed.host.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
|
||||
}
|
||||
)
|
||||
.optional(),
|
||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
TERMS_URL: z
|
||||
.string()
|
||||
@@ -190,7 +206,7 @@ export const env = createEnv({
|
||||
SMTP_AUTHENTICATED: process.env.SMTP_AUTHENTICATED,
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
SURVEY_URL: process.env.SURVEY_URL,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
|
||||
44
apps/web/lib/getPublicUrl.test.ts
Normal file
44
apps/web/lib/getPublicUrl.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock constants module
|
||||
const envMock = {
|
||||
env: {
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
PUBLIC_URL: undefined as string | undefined,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/env", () => envMock);
|
||||
|
||||
describe("getPublicDomain", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should return WEBAPP_URL when PUBLIC_URL is not set", async () => {
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("should return PUBLIC_URL when it is set", async () => {
|
||||
envMock.env.PUBLIC_URL = "https://surveys.example.com";
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("https://surveys.example.com");
|
||||
});
|
||||
|
||||
test("should handle empty string PUBLIC_URL by returning WEBAPP_URL", async () => {
|
||||
envMock.env.PUBLIC_URL = "";
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("should handle undefined PUBLIC_URL by returning WEBAPP_URL", async () => {
|
||||
envMock.env.PUBLIC_URL = undefined;
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
13
apps/web/lib/getPublicUrl.ts
Normal file
13
apps/web/lib/getPublicUrl.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import "server-only";
|
||||
import { env } from "./env";
|
||||
|
||||
const WEBAPP_URL =
|
||||
env.WEBAPP_URL ?? (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : "") ?? "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Returns the public domain URL
|
||||
* Uses PUBLIC_URL if set, otherwise falls back to WEBAPP_URL
|
||||
*/
|
||||
export const getPublicDomain = (): string => {
|
||||
return env.PUBLIC_URL && env.PUBLIC_URL.trim() !== "" ? env.PUBLIC_URL : WEBAPP_URL;
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Create a mock module for constants with proper types
|
||||
const constantsMock = {
|
||||
SURVEY_URL: undefined as string | undefined,
|
||||
WEBAPP_URL: "http://localhost:3000" as string,
|
||||
};
|
||||
|
||||
// Mock the constants module
|
||||
vi.mock("./constants", () => constantsMock);
|
||||
|
||||
describe("getSurveyDomain", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the mock values before each test
|
||||
constantsMock.SURVEY_URL = undefined;
|
||||
constantsMock.WEBAPP_URL = "http://localhost:3000";
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should return WEBAPP_URL when SURVEY_URL is not set", async () => {
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("should return SURVEY_URL when it is set", async () => {
|
||||
constantsMock.SURVEY_URL = "https://surveys.example.com";
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("https://surveys.example.com");
|
||||
});
|
||||
|
||||
test("should handle empty string SURVEY_URL by returning WEBAPP_URL", async () => {
|
||||
constantsMock.SURVEY_URL = "";
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("should handle undefined SURVEY_URL by returning WEBAPP_URL", async () => {
|
||||
constantsMock.SURVEY_URL = undefined;
|
||||
const { getSurveyDomain } = await import("./getSurveyUrl");
|
||||
const domain = getSurveyDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import "server-only";
|
||||
import { SURVEY_URL, WEBAPP_URL } from "./constants";
|
||||
|
||||
/**
|
||||
* Returns the base URL for public surveys
|
||||
* Uses SURVEY_URL if set, otherwise falls back to WEBAPP_URL
|
||||
*/
|
||||
export const getSurveyDomain = (): string => {
|
||||
return SURVEY_URL || WEBAPP_URL;
|
||||
};
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { OrganizationRole, Prisma, WidgetPlacement } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { getProject, getProjectByEnvironmentId, getProjects, getUserProjects } from "./service";
|
||||
import {
|
||||
getProject,
|
||||
getProjectByEnvironmentId,
|
||||
getProjectEnvironmentsByOrganizationIds,
|
||||
getProjects,
|
||||
getUserProjects,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -35,13 +41,20 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
|
||||
@@ -86,13 +99,20 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
|
||||
@@ -144,13 +164,20 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
@@ -162,23 +189,29 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "admin",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: OrganizationRole.owner,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
@@ -210,23 +243,29 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: OrganizationRole.member,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
@@ -278,23 +317,29 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "admin",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: OrganizationRole.owner,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
@@ -326,13 +371,20 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
@@ -344,13 +396,20 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -382,13 +441,20 @@ describe("Project Service", () => {
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
config: {
|
||||
channel: null,
|
||||
industry: null,
|
||||
},
|
||||
placement: WidgetPlacement.bottomRight,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
},
|
||||
logo: null,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -418,4 +484,68 @@ describe("Project Service", () => {
|
||||
|
||||
await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
environments: [],
|
||||
},
|
||||
{
|
||||
environments: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
|
||||
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: {
|
||||
in: [organizationId1, organizationId2],
|
||||
},
|
||||
},
|
||||
select: { environments: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: {
|
||||
in: [organizationId1, organizationId2],
|
||||
},
|
||||
},
|
||||
select: { environments: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => {
|
||||
await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,3 +170,31 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getProjectEnvironmentsByOrganizationIds = reactCache(
|
||||
async (organizationIds: string[]): Promise<Pick<TProject, "environments">[]> => {
|
||||
validateInputs([organizationIds, ZId.array()]);
|
||||
try {
|
||||
if (organizationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
organizationId: {
|
||||
in: organizationIds,
|
||||
},
|
||||
},
|
||||
select: { environments: true },
|
||||
});
|
||||
|
||||
return projects;
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(err.message);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -15,6 +15,15 @@ vi.mock("@aws-sdk/client-s3", () => ({
|
||||
GetObjectCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aws-sdk/s3-presigned-post", () => ({
|
||||
createPresignedPost: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
url: "https://test-bucket.s3.test-region.amazonaws.com",
|
||||
fields: { key: "test-key", policy: "test-policy" },
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("../constants", () => ({
|
||||
S3_ACCESS_KEY: "test-access-key",
|
||||
@@ -34,11 +43,32 @@ vi.mock("../constants", () => ({
|
||||
UPLOADS_DIR: "/tmp/uploads",
|
||||
}));
|
||||
|
||||
// Mock getPublicDomain
|
||||
vi.mock("../getPublicUrl", () => ({
|
||||
getPublicDomain: () => "https://public-domain.com",
|
||||
}));
|
||||
|
||||
// Mock crypto functions
|
||||
vi.mock("crypto", () => ({
|
||||
randomUUID: () => "test-uuid",
|
||||
}));
|
||||
|
||||
// Mock local signed url generation
|
||||
vi.mock("../crypto", () => ({
|
||||
generateLocalSignedUrl: () => ({
|
||||
signature: "test-signature",
|
||||
timestamp: 123456789,
|
||||
uuid: "test-uuid",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock env
|
||||
vi.mock("../env", () => ({
|
||||
env: {
|
||||
S3_BUCKET_NAME: "test-bucket",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Storage Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -131,4 +161,38 @@ describe("Storage Service", () => {
|
||||
await expect(putFile(fileName, fileBuffer, accessType, environmentId)).rejects.toThrow("Upload failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUploadSignedUrl", () => {
|
||||
let getUploadSignedUrl: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const serviceModule = await import("./service");
|
||||
getUploadSignedUrl = serviceModule.getUploadSignedUrl;
|
||||
});
|
||||
|
||||
test("should use PUBLIC_URL for public files with S3", async () => {
|
||||
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public");
|
||||
|
||||
expect(result.fileUrl).toContain("https://public-domain.com");
|
||||
expect(result.fileUrl).toMatch(
|
||||
/https:\/\/public-domain\.com\/storage\/env123\/public\/test--fid--test-uuid\.jpg/
|
||||
);
|
||||
});
|
||||
|
||||
test("should use WEBAPP_URL for private files with S3", async () => {
|
||||
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private");
|
||||
|
||||
expect(result.fileUrl).toContain("http://test-webapp");
|
||||
expect(result.fileUrl).toMatch(
|
||||
/http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/
|
||||
);
|
||||
});
|
||||
|
||||
test("should contain signed URL and presigned fields for S3", async () => {
|
||||
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public");
|
||||
|
||||
expect(result.signedUrl).toBe("https://test-bucket.s3.test-region.amazonaws.com");
|
||||
expect(result.presignedFields).toEqual({ key: "test-key", policy: "test-policy" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "../constants";
|
||||
import { generateLocalSignedUrl } from "../crypto";
|
||||
import { env } from "../env";
|
||||
import { getPublicDomain } from "../getPublicUrl";
|
||||
|
||||
// S3Client Singleton
|
||||
let s3ClientInstance: S3Client | null = null;
|
||||
@@ -165,6 +166,10 @@ export const getUploadSignedUrl = async (
|
||||
|
||||
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
|
||||
|
||||
// Use PUBLIC_URL for public files, WEBAPP_URL for private files
|
||||
const publicDomain = getPublicDomain();
|
||||
const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL;
|
||||
|
||||
// handle the local storage case first
|
||||
if (!isS3Configured()) {
|
||||
try {
|
||||
@@ -173,7 +178,7 @@ export const getUploadSignedUrl = async (
|
||||
return {
|
||||
signedUrl:
|
||||
accessType === "private"
|
||||
? new URL(`${WEBAPP_URL}/api/v1/client/${environmentId}/storage/local`).href
|
||||
? new URL(`${publicDomain}/api/v1/client/${environmentId}/storage/local`).href
|
||||
: new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href,
|
||||
signingData: {
|
||||
signature,
|
||||
@@ -181,7 +186,7 @@ export const getUploadSignedUrl = async (
|
||||
uuid,
|
||||
},
|
||||
updatedFileName,
|
||||
fileUrl: new URL(`${WEBAPP_URL}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
|
||||
fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err;
|
||||
@@ -200,7 +205,7 @@ export const getUploadSignedUrl = async (
|
||||
return {
|
||||
signedUrl,
|
||||
presignedFields,
|
||||
fileUrl: new URL(`${WEBAPP_URL}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
|
||||
fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
|
||||
};
|
||||
} catch (err) {
|
||||
throw err;
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
|
||||
}
|
||||
},
|
||||
"reset_password": "Passwort zurücksetzen"
|
||||
"reset_password": "Passwort zurücksetzen",
|
||||
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Konto erstellen",
|
||||
@@ -79,7 +80,7 @@
|
||||
},
|
||||
"signup_without_verification_success": {
|
||||
"user_successfully_created": "Benutzer erfolgreich erstellt",
|
||||
"user_successfully_created_description": "Dein neuer Benutzer wurde erfolgreich erstellt. Bitte klicke auf den untenstehenden Button und melde Dich in deinem Konto an."
|
||||
"user_successfully_created_info": "Wir haben nach einem Konto gesucht, das mit {email} verknüpft ist. Wenn keines existierte, haben wir eines für Dich erstellt. Wenn bereits ein Konto existierte, wurden keine Änderungen vorgenommen. Bitte melde Dich unten an, um fortzufahren."
|
||||
},
|
||||
"testimonial_1": "Als open-source Firma ist uns Datenschutz extrem wichtig! Formbricks bietet die perfekte Mischung aus modernster Technologie und solidem Datenschutz.",
|
||||
"testimonial_all_features_included": "Alle Funktionen enthalten",
|
||||
@@ -91,11 +92,10 @@
|
||||
"invalid_token": "Ungültiges Token ☹️",
|
||||
"new_email_verification_success": "Wenn die Adresse gültig ist, wurde eine Bestätigungs-E-Mail gesendet.",
|
||||
"no_email_provided": "Keine E-Mail bereitgestellt",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.",
|
||||
"please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse",
|
||||
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
|
||||
"verification_email_resent_successfully": "Bestätigungs-E-Mail gesendet! Bitte überprüfe dein Postfach.",
|
||||
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
|
||||
"verification_email_successfully_sent_info": "Wenn ein Konto mit {email} verknüpft ist, haben wir einen Bestätigungslink an diese Adresse gesendet. Bitte überprüfe dein Postfach, um die Anmeldung abzuschließen.",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
|
||||
},
|
||||
"verify": {
|
||||
@@ -135,7 +135,6 @@
|
||||
"app_survey": "App-Umfrage",
|
||||
"apply_filters": "Filter anwenden",
|
||||
"are_you_sure": "Bist Du sicher?",
|
||||
"are_you_sure_this_action_cannot_be_undone": "Bist Du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"attributes": "Attribute",
|
||||
"avatar": "Avatar",
|
||||
"back": "Zurück",
|
||||
@@ -192,7 +191,6 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
"embed": "Einbetten",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
@@ -310,7 +308,6 @@
|
||||
"project_not_found": "Projekt nicht gefunden",
|
||||
"project_permission_not_found": "Projekt-Berechtigung nicht gefunden",
|
||||
"projects": "Projekte",
|
||||
"projects_limit_reached": "Projektlimit erreicht",
|
||||
"question": "Frage",
|
||||
"question_id": "Frage-ID",
|
||||
"questions": "Fragen",
|
||||
@@ -318,6 +315,7 @@
|
||||
"remove": "Entfernen",
|
||||
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
|
||||
"report_survey": "Umfrage melden",
|
||||
"request_pricing": "Preise anfragen",
|
||||
"request_trial_license": "Testlizenz anfordern",
|
||||
"reset_to_default": "Auf Standard zurücksetzen",
|
||||
"response": "Antwort",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weekly_summary": "Wöchentliche Zusammenfassung",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"yes": "Ja",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
|
||||
"you_are_not_authorised_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion auszuführen.",
|
||||
@@ -598,6 +595,7 @@
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
@@ -634,6 +632,7 @@
|
||||
"airtable_integration": "Airtable Integration",
|
||||
"airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.",
|
||||
"airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert",
|
||||
"airtable_logo": "Airtable-Logo",
|
||||
"connect_with_airtable": "Mit Airtable verbinden",
|
||||
"link_airtable_table": "Airtable Tabelle verknüpfen",
|
||||
"link_new_table": "Neue Tabelle verknüpfen",
|
||||
@@ -701,7 +700,6 @@
|
||||
"select_a_database": "Datenbank auswählen",
|
||||
"select_a_field_to_map": "Wähle ein Feld zum Zuordnen aus",
|
||||
"select_a_survey_question": "Wähle eine Umfragefrage aus",
|
||||
"sync_responses_with_a_notion_database": "Antworten mit einer Datenbank in Notion synchronisieren",
|
||||
"update_connection": "Notion erneut verbinden",
|
||||
"update_connection_tooltip": "Verbinde die Integration erneut, um neu hinzugefügte Datenbanken einzuschließen. Deine bestehenden Integrationen bleiben erhalten."
|
||||
},
|
||||
@@ -723,6 +721,7 @@
|
||||
"slack_integration": "Slack Integration",
|
||||
"slack_integration_description": "Sende Antworten direkt an Slack.",
|
||||
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.",
|
||||
"slack_logo": "Slack-Logo",
|
||||
"slack_reconnect_button": "Erneut verbinden",
|
||||
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
|
||||
},
|
||||
@@ -907,8 +906,7 @@
|
||||
"tag_already_exists": "Tag existiert bereits",
|
||||
"tag_deleted": "Tag gelöscht",
|
||||
"tag_updated": "Tag aktualisiert",
|
||||
"tags_merged": "Tags zusammengeführt",
|
||||
"unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen"
|
||||
"tags_merged": "Tags zusammengeführt"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "Teams verwalten",
|
||||
@@ -981,63 +979,53 @@
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10,000 monatliche Antworten",
|
||||
"1500_monthly_responses": "1,500 monatliche Antworten",
|
||||
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer",
|
||||
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
|
||||
"1000_monthly_responses": "1,000 monatliche Antworten",
|
||||
"1_project": "1 Projekt",
|
||||
"2000_contacts": "2,000 Kontakte",
|
||||
"3_projects": "3 Projekte",
|
||||
"5000_monthly_responses": "5,000 monatliche Antworten",
|
||||
"5_projects": "5 Projekte",
|
||||
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
|
||||
"advanced_targeting": "Erweitertes Targeting",
|
||||
"7500_contacts": "7,500 Kontakte",
|
||||
"all_integrations": "Alle Integrationen",
|
||||
"all_surveying_features": "Alle Umfragefunktionen",
|
||||
"annually": "Jährlich",
|
||||
"api_webhooks": "API & Webhooks",
|
||||
"app_surveys": "In-app Umfragen",
|
||||
"contact_us": "Kontaktiere uns",
|
||||
"attribute_based_targeting": "Attributbasiertes Targeting",
|
||||
"current": "aktuell",
|
||||
"current_plan": "Aktueller Plan",
|
||||
"current_tier_limit": "Aktuelles Limit",
|
||||
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit",
|
||||
"custom": "Benutzerdefiniert & Skalierung",
|
||||
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
|
||||
"custom_project_limit": "Benutzerdefiniertes Projektlimit",
|
||||
"customer_success_manager": "Customer Success Manager",
|
||||
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
|
||||
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
|
||||
"email_support": "E-Mail-Support",
|
||||
"enterprise": "Enterprise",
|
||||
"email_follow_ups": "E-Mail Follow-ups",
|
||||
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
|
||||
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
|
||||
"everything_in_free": "Alles in 'Free''",
|
||||
"everything_in_scale": "Alles in 'Scale''",
|
||||
"everything_in_startup": "Alles in 'Startup''",
|
||||
"free": "Kostenlos",
|
||||
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
|
||||
"get_2_months_free": "2 Monate gratis",
|
||||
"get_in_touch": "Kontaktiere uns",
|
||||
"hosted_in_frankfurt": "Gehostet in Frankfurt",
|
||||
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
|
||||
"link_surveys": "Umfragen verlinken (teilbar)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
|
||||
"manage_card_details": "Karteninformationen verwalten",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"monthly_identified_users": "Monatlich identifizierte Nutzer",
|
||||
"multi_language_surveys": "Mehrsprachige Umfragen",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
|
||||
"premium_support_with_slas": "Premium-Support mit SLAs",
|
||||
"priority_support": "Priorisierter Support",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"say_hi": "Sag Hi!",
|
||||
"scale": "Scale",
|
||||
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
|
||||
"startup": "Start-up",
|
||||
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
|
||||
"switch_plan": "Plan wechseln",
|
||||
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
|
||||
"team_access_roles": "Rollen für Teammitglieder",
|
||||
"technical_onboarding": "Technische Einführung",
|
||||
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
|
||||
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
|
||||
"unlimited_miu": "Unbegrenzte MIU",
|
||||
"unlimited_projects": "Unbegrenzte Projekte",
|
||||
"unlimited_responses": "Unbegrenzte Antworten",
|
||||
@@ -1076,6 +1064,7 @@
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.",
|
||||
"customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan",
|
||||
"delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.",
|
||||
"delete_organization": "Organisation löschen",
|
||||
"delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen",
|
||||
"delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
|
||||
@@ -1232,8 +1221,9 @@
|
||||
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
|
||||
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
|
||||
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
|
||||
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
|
||||
"copy_survey_success": "Umfrage erfolgreich kopiert!",
|
||||
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
|
||||
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
|
||||
@@ -1306,7 +1296,6 @@
|
||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||
"card_background_color": "Hintergrundfarbe der Karte",
|
||||
"card_border_color": "Farbe des Kartenrandes",
|
||||
"card_shadow_color": "Farbton des Kartenschattens",
|
||||
"card_styling": "Kartenstil",
|
||||
"casual": "Lässig",
|
||||
"caution_edit_duplicate": "Duplizieren & bearbeiten",
|
||||
@@ -1331,7 +1320,6 @@
|
||||
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
|
||||
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
|
||||
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
|
||||
"change_the_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.",
|
||||
"changes_saved": "Änderungen gespeichert.",
|
||||
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
|
||||
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
|
||||
@@ -1788,6 +1776,7 @@
|
||||
"setup_instructions": "Einrichtung",
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_results": "Ergebnisse teilen",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"share_the_link": "Teile den Link",
|
||||
"share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "You can now log in with your new password"
|
||||
}
|
||||
},
|
||||
"reset_password": "Reset password"
|
||||
"reset_password": "Reset password",
|
||||
"reset_password_description": "You will be logged out to reset your password."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Create an account",
|
||||
@@ -79,7 +80,7 @@
|
||||
},
|
||||
"signup_without_verification_success": {
|
||||
"user_successfully_created": "User successfully created",
|
||||
"user_successfully_created_description": "Your new user has been created successfully. Please click the button below and sign in to your account."
|
||||
"user_successfully_created_info": "We’ve checked for an account associated with {email}. If none existed, we’ve created one for you. If an account already existed, no changes were made. Please log in below to continue."
|
||||
},
|
||||
"testimonial_1": "We measure the clarity of our docs and learn from churn all on one platform. Great product, very responsive team!",
|
||||
"testimonial_all_features_included": "All features included",
|
||||
@@ -91,11 +92,10 @@
|
||||
"invalid_token": "Invalid token ☹️",
|
||||
"new_email_verification_success": "If the address is valid, a verification email has been sent.",
|
||||
"no_email_provided": "No email provided",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.",
|
||||
"please_confirm_your_email_address": "Please confirm your email address",
|
||||
"resend_verification_email": "Resend verification email",
|
||||
"verification_email_resent_successfully": "Verification email sent! Please check your inbox.",
|
||||
"we_sent_an_email_to": "We sent an email to {email}. ",
|
||||
"verification_email_successfully_sent_info": "If there’s an account associated with {email}, we’ve sent a verification link to that address. Please check your inbox to complete the sign-up.",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
|
||||
},
|
||||
"verify": {
|
||||
@@ -135,7 +135,6 @@
|
||||
"app_survey": "App Survey",
|
||||
"apply_filters": "Apply filters",
|
||||
"are_you_sure": "Are you sure?",
|
||||
"are_you_sure_this_action_cannot_be_undone": "Are you sure? This action cannot be undone.",
|
||||
"attributes": "Attributes",
|
||||
"avatar": "Avatar",
|
||||
"back": "Back",
|
||||
@@ -192,7 +191,6 @@
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"embed": "Embed",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
@@ -310,7 +308,6 @@
|
||||
"project_not_found": "Project not found",
|
||||
"project_permission_not_found": "Project permission not found",
|
||||
"projects": "Projects",
|
||||
"projects_limit_reached": "Projects limit reached",
|
||||
"question": "Question",
|
||||
"question_id": "Question ID",
|
||||
"questions": "Questions",
|
||||
@@ -318,6 +315,7 @@
|
||||
"remove": "Remove",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
"report_survey": "Report Survey",
|
||||
"request_pricing": "Request Pricing",
|
||||
"request_trial_license": "Request trial license",
|
||||
"reset_to_default": "Reset to default",
|
||||
"response": "Response",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_survey": "Website Survey",
|
||||
"weekly_summary": "Weekly summary",
|
||||
"welcome_card": "Welcome card",
|
||||
"yes": "Yes",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorised_to_perform_this_action": "You are not authorised to perform this action.",
|
||||
@@ -598,6 +595,7 @@
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
|
||||
"first_name": "First Name",
|
||||
"last_name": "Last Name",
|
||||
"no_responses_found": "No responses found",
|
||||
@@ -634,6 +632,7 @@
|
||||
"airtable_integration": "Airtable Integration",
|
||||
"airtable_integration_description": "Sync responses directly with Airtable.",
|
||||
"airtable_integration_is_not_configured": "Airtable Integration is not configured",
|
||||
"airtable_logo": "Airtable logo",
|
||||
"connect_with_airtable": "Connect with Airtable",
|
||||
"link_airtable_table": "Link Airtable Table",
|
||||
"link_new_table": "Link new table",
|
||||
@@ -701,7 +700,6 @@
|
||||
"select_a_database": "Select Database",
|
||||
"select_a_field_to_map": "Select a field to map",
|
||||
"select_a_survey_question": "Select a survey question",
|
||||
"sync_responses_with_a_notion_database": "Sync responses with a Notion Database",
|
||||
"update_connection": "Reconnect Notion",
|
||||
"update_connection_tooltip": "Reconnect the integration to include newly added databases. Your existing integrations will remain intact."
|
||||
},
|
||||
@@ -723,6 +721,7 @@
|
||||
"slack_integration": "Slack Integration",
|
||||
"slack_integration_description": "Send responses directly to Slack.",
|
||||
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.",
|
||||
"slack_logo": "Slack logo",
|
||||
"slack_reconnect_button": "Reconnect",
|
||||
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
|
||||
},
|
||||
@@ -907,8 +906,7 @@
|
||||
"tag_already_exists": "Tag already exists",
|
||||
"tag_deleted": "Tag deleted",
|
||||
"tag_updated": "Tag updated",
|
||||
"tags_merged": "Tags merged",
|
||||
"unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields"
|
||||
"tags_merged": "Tags merged"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "Manage teams",
|
||||
@@ -981,63 +979,53 @@
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Monthly Responses",
|
||||
"1500_monthly_responses": "1500 Monthly Responses",
|
||||
"2000_monthly_identified_users": "2000 Monthly Identified Users",
|
||||
"30000_monthly_identified_users": "30000 Monthly Identified Users",
|
||||
"1000_monthly_responses": "Monthly 1,000 Responses",
|
||||
"1_project": "1 Project",
|
||||
"2000_contacts": "2,000 Contacts",
|
||||
"3_projects": "3 Projects",
|
||||
"5000_monthly_responses": "5,000 Monthly Responses",
|
||||
"5_projects": "5 Projects",
|
||||
"7500_monthly_identified_users": "7500 Monthly Identified Users",
|
||||
"advanced_targeting": "Advanced Targeting",
|
||||
"7500_contacts": "7,500 Contacts",
|
||||
"all_integrations": "All Integrations",
|
||||
"all_surveying_features": "All surveying features",
|
||||
"annually": "Annually",
|
||||
"api_webhooks": "API & Webhooks",
|
||||
"app_surveys": "App Surveys",
|
||||
"contact_us": "Contact Us",
|
||||
"attribute_based_targeting": "Attribute-based Targeting",
|
||||
"current": "Current",
|
||||
"current_plan": "Current Plan",
|
||||
"current_tier_limit": "Current Tier Limit",
|
||||
"custom_miu_limit": "Custom MIU limit",
|
||||
"custom": "Custom & Scale",
|
||||
"custom_contacts_limit": "Custom Contacts Limit",
|
||||
"custom_project_limit": "Custom Project Limit",
|
||||
"customer_success_manager": "Customer Success Manager",
|
||||
"custom_response_limit": "Custom Response Limit",
|
||||
"email_embedded_surveys": "Email Embedded Surveys",
|
||||
"email_support": "Email Support",
|
||||
"enterprise": "Enterprise",
|
||||
"email_follow_ups": "Email Follow-ups",
|
||||
"enterprise_description": "Premium support and custom limits.",
|
||||
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
|
||||
"everything_in_free": "Everything in Free",
|
||||
"everything_in_scale": "Everything in Scale",
|
||||
"everything_in_startup": "Everything in Startup",
|
||||
"free": "Free",
|
||||
"free_description": "Unlimited Surveys, Team Members, and more.",
|
||||
"get_2_months_free": "Get 2 months free",
|
||||
"get_in_touch": "Get in touch",
|
||||
"hosted_in_frankfurt": "Hosted in Frankfurt",
|
||||
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
|
||||
"link_surveys": "Link Surveys (Shareable)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
|
||||
"manage_card_details": "Manage Card Details",
|
||||
"manage_subscription": "Manage Subscription",
|
||||
"monthly": "Monthly",
|
||||
"monthly_identified_users": "Monthly Identified Users",
|
||||
"multi_language_surveys": "Multi-Language Surveys",
|
||||
"per_month": "per month",
|
||||
"per_year": "per year",
|
||||
"plan_upgraded_successfully": "Plan upgraded successfully",
|
||||
"premium_support_with_slas": "Premium support with SLAs",
|
||||
"priority_support": "Priority Support",
|
||||
"remove_branding": "Remove Branding",
|
||||
"say_hi": "Say Hi!",
|
||||
"scale": "Scale",
|
||||
"scale_description": "Advanced features for scaling your business.",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Everything in Free with additional features.",
|
||||
"switch_plan": "Switch Plan",
|
||||
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
|
||||
"team_access_roles": "Team Access Roles",
|
||||
"technical_onboarding": "Technical Onboarding",
|
||||
"unable_to_upgrade_plan": "Unable to upgrade plan",
|
||||
"unlimited_apps_websites": "Unlimited Apps & Websites",
|
||||
"unlimited_miu": "Unlimited MIU",
|
||||
"unlimited_projects": "Unlimited Projects",
|
||||
"unlimited_responses": "Unlimited Responses",
|
||||
@@ -1076,6 +1064,7 @@
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_new_organization_description": "Create a new organization to handle a different set of projects.",
|
||||
"customize_email_with_a_higher_plan": "Customize email with a higher plan",
|
||||
"delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.",
|
||||
"delete_organization": "Delete Organization",
|
||||
"delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes",
|
||||
"delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:",
|
||||
@@ -1232,8 +1221,9 @@
|
||||
"copy_survey_description": "Copy this survey to another environment",
|
||||
"copy_survey_error": "Failed to copy survey",
|
||||
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
|
||||
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
|
||||
"copy_survey_success": "Survey copied successfully!",
|
||||
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.",
|
||||
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
|
||||
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
|
||||
@@ -1306,7 +1296,6 @@
|
||||
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
||||
"card_background_color": "Card background color",
|
||||
"card_border_color": "Card border color",
|
||||
"card_shadow_color": "Card shadow color",
|
||||
"card_styling": "Card Styling",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicate & edit",
|
||||
@@ -1331,7 +1320,6 @@
|
||||
"change_the_brand_color_of_the_survey": "Change the brand color of the survey.",
|
||||
"change_the_placement_of_this_survey": "Change the placement of this survey.",
|
||||
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
|
||||
"change_the_shadow_color_of_the_card": "Change the shadow color of the card.",
|
||||
"changes_saved": "Changes saved.",
|
||||
"character_limit_toggle_description": "Limit how short or long an answer can be.",
|
||||
"character_limit_toggle_title": "Add character limits",
|
||||
@@ -1788,6 +1776,7 @@
|
||||
"setup_instructions": "Setup instructions",
|
||||
"setup_integrations": "Setup integrations",
|
||||
"share_results": "Share results",
|
||||
"share_survey": "Share survey",
|
||||
"share_the_link": "Share the link",
|
||||
"share_the_link_to_get_responses": "Share the link to get responses",
|
||||
"show_all_responses_that_match": "Show all responses that match",
|
||||
@@ -1893,12 +1882,12 @@
|
||||
},
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Please also check your spam folder if you don't see the email in your inbox.",
|
||||
"completed": "This free & open-source survey has been closed.",
|
||||
"create_your_own": "Create your own",
|
||||
"completed": "This survey is closed.",
|
||||
"create_your_own": "Create your own open-source survey",
|
||||
"enter_pin": "This survey is protected. Enter the PIN below",
|
||||
"just_curious": "Just curious?",
|
||||
"link_invalid": "This survey can only be taken by invitation.",
|
||||
"paused": "This free & open-source survey is temporarily paused.",
|
||||
"paused": "This survey is temporarily paused.",
|
||||
"please_try_again_with_the_original_link": "Please try again with the original link",
|
||||
"preview_survey_questions": "Preview survey questions.",
|
||||
"question_preview": "Question Preview",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
}
|
||||
},
|
||||
"reset_password": "Réinitialiser le mot de passe"
|
||||
"reset_password": "Réinitialiser le mot de passe",
|
||||
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Créer un compte",
|
||||
@@ -79,7 +80,7 @@
|
||||
},
|
||||
"signup_without_verification_success": {
|
||||
"user_successfully_created": "Utilisateur créé avec succès",
|
||||
"user_successfully_created_description": "Votre nouvel utilisateur a été créé avec succès. Veuillez cliquer sur le bouton ci-dessous et vous connecter à votre compte."
|
||||
"user_successfully_created_info": "Nous avons vérifié s'il existait un compte associé à {email}. Si aucun n'existait, nous en avons créé un pour vous. Si un compte existait déjà, aucune modification n'a été apportée. Veuillez vous connecter ci-dessous pour continuer."
|
||||
},
|
||||
"testimonial_1": "Nous mesurons la clarté de nos documents et apprenons des abandons, le tout sur une seule plateforme. Excellent produit, équipe très réactive !",
|
||||
"testimonial_all_features_included": "Toutes les fonctionnalités incluses",
|
||||
@@ -91,11 +92,10 @@
|
||||
"invalid_token": "Jeton non valide ☹️",
|
||||
"new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.",
|
||||
"no_email_provided": "Aucun e-mail fourni",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.",
|
||||
"please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.",
|
||||
"resend_verification_email": "Renvoyer l'email de vérification",
|
||||
"verification_email_resent_successfully": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
|
||||
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
|
||||
"verification_email_successfully_sent_info": "Si un compte est associé à {email}, nous avons envoyé un lien de vérification à cette adresse. Veuillez vérifier votre boîte de réception pour terminer l'inscription.",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
|
||||
},
|
||||
"verify": {
|
||||
@@ -135,7 +135,6 @@
|
||||
"app_survey": "Sondage d'application",
|
||||
"apply_filters": "Appliquer des filtres",
|
||||
"are_you_sure": "Es-tu sûr ?",
|
||||
"are_you_sure_this_action_cannot_be_undone": "Êtes-vous sûr ? Cette action ne peut pas être annulée.",
|
||||
"attributes": "Attributs",
|
||||
"avatar": "Avatar",
|
||||
"back": "Retour",
|
||||
@@ -192,7 +191,6 @@
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
"embed": "Intégrer",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
@@ -310,7 +308,6 @@
|
||||
"project_not_found": "Projet non trouvé",
|
||||
"project_permission_not_found": "Autorisation de projet non trouvée",
|
||||
"projects": "Projets",
|
||||
"projects_limit_reached": "Limite de projets atteinte",
|
||||
"question": "Question",
|
||||
"question_id": "ID de la question",
|
||||
"questions": "Questions",
|
||||
@@ -318,6 +315,7 @@
|
||||
"remove": "Retirer",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
"report_survey": "Rapport d'enquête",
|
||||
"request_pricing": "Demander la tarification",
|
||||
"request_trial_license": "Demander une licence d'essai",
|
||||
"reset_to_default": "Réinitialiser par défaut",
|
||||
"response": "Réponse",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_survey": "Sondage de site web",
|
||||
"weekly_summary": "Résumé hebdomadaire",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
"yes": "Oui",
|
||||
"you": "Vous",
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
"you_are_not_authorised_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
@@ -598,6 +595,7 @@
|
||||
"contact_not_found": "Aucun contact trouvé",
|
||||
"contacts_table_refresh": "Rafraîchir les contacts",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom de famille",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
@@ -634,6 +632,7 @@
|
||||
"airtable_integration": "Intégration Airtable",
|
||||
"airtable_integration_description": "Synchronisez les réponses directement avec Airtable.",
|
||||
"airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée",
|
||||
"airtable_logo": "Logo Airtable",
|
||||
"connect_with_airtable": "Se connecter à Airtable",
|
||||
"link_airtable_table": "Lier la table Airtable",
|
||||
"link_new_table": "Lier nouvelle table",
|
||||
@@ -701,7 +700,6 @@
|
||||
"select_a_database": "Sélectionner la base de données",
|
||||
"select_a_field_to_map": "Sélectionnez un champ à mapper",
|
||||
"select_a_survey_question": "Sélectionnez une question d'enquête",
|
||||
"sync_responses_with_a_notion_database": "Synchroniser les réponses avec une base de données Notion",
|
||||
"update_connection": "Reconnecter Notion",
|
||||
"update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes."
|
||||
},
|
||||
@@ -723,6 +721,7 @@
|
||||
"slack_integration": "Intégration Slack",
|
||||
"slack_integration_description": "Envoyez les réponses directement sur Slack.",
|
||||
"slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.",
|
||||
"slack_logo": "logo Slack",
|
||||
"slack_reconnect_button": "Reconnecter",
|
||||
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
|
||||
},
|
||||
@@ -907,8 +906,7 @@
|
||||
"tag_already_exists": "Le tag existe déjà",
|
||||
"tag_deleted": "Tag supprimé",
|
||||
"tag_updated": "Étiquette mise à jour",
|
||||
"tags_merged": "Étiquettes fusionnées",
|
||||
"unique_constraint_failed_on_the_fields": "Échec de la contrainte unique sur les champs"
|
||||
"tags_merged": "Étiquettes fusionnées"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "Gérer les équipes",
|
||||
@@ -981,63 +979,53 @@
|
||||
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Réponses Mensuelles",
|
||||
"1500_monthly_responses": "1500 Réponses Mensuelles",
|
||||
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
|
||||
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
|
||||
"1000_monthly_responses": "1000 Réponses Mensuelles",
|
||||
"1_project": "1 Projet",
|
||||
"2000_contacts": "2 000 Contacts",
|
||||
"3_projects": "3 Projets",
|
||||
"5000_monthly_responses": "5,000 Réponses Mensuelles",
|
||||
"5_projects": "5 Projets",
|
||||
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
|
||||
"advanced_targeting": "Ciblage Avancé",
|
||||
"7500_contacts": "7 500 Contacts",
|
||||
"all_integrations": "Toutes les intégrations",
|
||||
"all_surveying_features": "Tous les outils d'arpentage",
|
||||
"annually": "Annuellement",
|
||||
"api_webhooks": "API et Webhooks",
|
||||
"app_surveys": "Sondages d'application",
|
||||
"contact_us": "Contactez-nous",
|
||||
"attribute_based_targeting": "Ciblage basé sur les attributs",
|
||||
"current": "Actuel",
|
||||
"current_plan": "Plan actuel",
|
||||
"current_tier_limit": "Limite de niveau actuel",
|
||||
"custom_miu_limit": "Limite MIU personnalisé",
|
||||
"custom": "Personnalisé et Échelle",
|
||||
"custom_contacts_limit": "Limite de contacts personnalisé",
|
||||
"custom_project_limit": "Limite de projet personnalisé",
|
||||
"customer_success_manager": "Responsable de la réussite client",
|
||||
"custom_response_limit": "Limite de réponse personnalisé",
|
||||
"email_embedded_surveys": "Sondages intégrés par e-mail",
|
||||
"email_support": "Support par e-mail",
|
||||
"enterprise": "Entreprise",
|
||||
"email_follow_ups": "Relances par e-mail",
|
||||
"enterprise_description": "Soutien premium et limites personnalisées.",
|
||||
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
|
||||
"everything_in_free": "Tout est gratuit",
|
||||
"everything_in_scale": "Tout à l'échelle",
|
||||
"everything_in_startup": "Tout dans le Startup",
|
||||
"free": "Gratuit",
|
||||
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
|
||||
"get_2_months_free": "Obtenez 2 mois gratuits",
|
||||
"get_in_touch": "Prenez contact",
|
||||
"hosted_in_frankfurt": "Hébergé à Francfort",
|
||||
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
|
||||
"link_surveys": "Sondages par lien (partageables)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
|
||||
"manage_card_details": "Gérer les détails de la carte",
|
||||
"manage_subscription": "Gérer l'abonnement",
|
||||
"monthly": "Mensuel",
|
||||
"monthly_identified_users": "Utilisateurs Identifiés Mensuels",
|
||||
"multi_language_surveys": "Sondages multilingues",
|
||||
"per_month": "par mois",
|
||||
"per_year": "par an",
|
||||
"plan_upgraded_successfully": "Plan mis à jour avec succès",
|
||||
"premium_support_with_slas": "Soutien premium avec SLA",
|
||||
"priority_support": "Soutien Prioritaire",
|
||||
"remove_branding": "Supprimer la marque",
|
||||
"say_hi": "Dis bonjour !",
|
||||
"scale": "Échelle",
|
||||
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
|
||||
"switch_plan": "Changer de plan",
|
||||
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
|
||||
"team_access_roles": "Rôles d'accès d'équipe",
|
||||
"technical_onboarding": "Intégration technique",
|
||||
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
|
||||
"unlimited_apps_websites": "Applications et sites Web illimités",
|
||||
"unlimited_miu": "MIU Illimité",
|
||||
"unlimited_projects": "Projets illimités",
|
||||
"unlimited_responses": "Réponses illimitées",
|
||||
@@ -1076,6 +1064,7 @@
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
|
||||
"customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
|
||||
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
|
||||
"delete_organization": "Supprimer l'organisation",
|
||||
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
|
||||
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
|
||||
@@ -1232,8 +1221,9 @@
|
||||
"copy_survey_description": "Copier cette enquête dans un autre environnement",
|
||||
"copy_survey_error": "Échec de la copie du sondage",
|
||||
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
|
||||
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
|
||||
"copy_survey_success": "Enquête copiée avec succès !",
|
||||
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.",
|
||||
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
|
||||
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
|
||||
@@ -1306,7 +1296,6 @@
|
||||
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
||||
"card_background_color": "Couleur de fond de la carte",
|
||||
"card_border_color": "Couleur de la bordure de la carte",
|
||||
"card_shadow_color": "Couleur de l'ombre de la carte",
|
||||
"card_styling": "Style de carte",
|
||||
"casual": "Décontracté",
|
||||
"caution_edit_duplicate": "Dupliquer et modifier",
|
||||
@@ -1331,7 +1320,6 @@
|
||||
"change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.",
|
||||
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
|
||||
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
|
||||
"change_the_shadow_color_of_the_card": "Changez la couleur de l'ombre de la carte.",
|
||||
"changes_saved": "Modifications enregistrées.",
|
||||
"character_limit_toggle_description": "Limitez la longueur des réponses.",
|
||||
"character_limit_toggle_title": "Ajouter des limites de caractères",
|
||||
@@ -1788,6 +1776,7 @@
|
||||
"setup_instructions": "Instructions d'installation",
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_results": "Partager les résultats",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"share_the_link": "Partager le lien",
|
||||
"share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Agora você pode fazer login com sua nova senha"
|
||||
}
|
||||
},
|
||||
"reset_password": "Redefinir senha"
|
||||
"reset_password": "Redefinir senha",
|
||||
"reset_password_description": "Você será desconectado para redefinir sua senha."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Cria uma conta",
|
||||
@@ -79,7 +80,7 @@
|
||||
},
|
||||
"signup_without_verification_success": {
|
||||
"user_successfully_created": "Usuário criado com sucesso",
|
||||
"user_successfully_created_description": "Seu novo usuário foi criado com sucesso. Por favor, clique no botão abaixo e faça login na sua conta."
|
||||
"user_successfully_created_info": "Verificamos se há uma conta associada a {email}. Se não existia, criamos uma para você. Se uma conta já existia, nenhuma alteração foi feita. Por favor, faça login abaixo para continuar."
|
||||
},
|
||||
"testimonial_1": "Mediamos a clareza dos nossos documentos e aprendemos com a rotatividade tudo em uma única plataforma. Ótimo produto, equipe muito atenciosa!",
|
||||
"testimonial_all_features_included": "Todas as funcionalidades incluídas",
|
||||
@@ -91,11 +92,10 @@
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum e-mail fornecido",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail",
|
||||
"resend_verification_email": "Reenviar e-mail de verificação",
|
||||
"verification_email_resent_successfully": "E-mail de verificação enviado! Por favor, verifique sua caixa de entrada.",
|
||||
"we_sent_an_email_to": "Enviamos um email para {email}",
|
||||
"verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviamos um link de verificação para esse endereço. Por favor, verifique sua caixa de entrada para completar o cadastro.",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
|
||||
},
|
||||
"verify": {
|
||||
@@ -135,7 +135,6 @@
|
||||
"app_survey": "Pesquisa de App",
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Certeza?",
|
||||
"are_you_sure_this_action_cannot_be_undone": "Tem certeza? Essa ação não pode ser desfeita.",
|
||||
"attributes": "atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
@@ -192,7 +191,6 @@
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"embed": "incorporar",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
@@ -310,7 +308,6 @@
|
||||
"project_not_found": "Projeto não encontrado",
|
||||
"project_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"projects": "Projetos",
|
||||
"projects_limit_reached": "Limites de projetos atingidos",
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da Pergunta",
|
||||
"questions": "Perguntas",
|
||||
@@ -318,6 +315,7 @@
|
||||
"remove": "remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Pesquisa",
|
||||
"request_pricing": "Solicitar Preços",
|
||||
"request_trial_license": "Pedir licença de teste",
|
||||
"reset_to_default": "Restaurar para o padrão",
|
||||
"response": "Resposta",
|
||||
@@ -358,7 +356,7 @@
|
||||
"start_free_trial": "Iniciar Teste Grátis",
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"styling": "estilização",
|
||||
"styling": "Estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
"survey": "Pesquisa",
|
||||
@@ -370,7 +368,7 @@
|
||||
"survey_paused": "Pesquisa pausada.",
|
||||
"survey_scheduled": "Pesquisa agendada.",
|
||||
"survey_type": "Tipo de Pesquisa",
|
||||
"surveys": "pesquisas",
|
||||
"surveys": "Pesquisas",
|
||||
"switch_organization": "Mudar organização",
|
||||
"switch_to": "Mudar para {environment}",
|
||||
"table_items_deleted_successfully": "{type}s deletados com sucesso",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_survey": "Pesquisa de Site",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"yes": "Sim",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorised_to_perform_this_action": "Você não tem autorização para fazer isso.",
|
||||
@@ -598,6 +595,7 @@
|
||||
"contact_not_found": "Nenhum contato encontrado",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"first_name": "Primeiro Nome",
|
||||
"last_name": "Sobrenome",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
@@ -634,6 +632,7 @@
|
||||
"airtable_integration": "Integração com Airtable",
|
||||
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
|
||||
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
|
||||
"airtable_logo": "Logo do Airtable",
|
||||
"connect_with_airtable": "Conectar com o Airtable",
|
||||
"link_airtable_table": "Vincular Tabela do Airtable",
|
||||
"link_new_table": "Vincular nova tabela",
|
||||
@@ -701,7 +700,6 @@
|
||||
"select_a_database": "Selecionar Banco de Dados",
|
||||
"select_a_field_to_map": "Selecione um campo para mapear",
|
||||
"select_a_survey_question": "Escolha uma pergunta da pesquisa",
|
||||
"sync_responses_with_a_notion_database": "Sincronizar respostas com um banco de dados do Notion",
|
||||
"update_connection": "Reconectar Notion",
|
||||
"update_connection_tooltip": "Reconecte a integração para incluir os novos bancos de dados adicionados. Suas integrações existentes permanecerão intactas."
|
||||
},
|
||||
@@ -723,6 +721,7 @@
|
||||
"slack_integration": "Integração com o Slack",
|
||||
"slack_integration_description": "Manda as respostas direto pro Slack.",
|
||||
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.",
|
||||
"slack_logo": "Logotipo do Slack",
|
||||
"slack_reconnect_button": "Reconectar",
|
||||
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
|
||||
},
|
||||
@@ -907,8 +906,7 @@
|
||||
"tag_already_exists": "Tag já existe",
|
||||
"tag_deleted": "Tag apagada",
|
||||
"tag_updated": "Tag atualizada",
|
||||
"tags_merged": "Tags mescladas",
|
||||
"unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos"
|
||||
"tags_merged": "Tags mescladas"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "Gerenciar Equipes",
|
||||
@@ -981,63 +979,53 @@
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
"1500_monthly_responses": "1500 Respostas Mensais",
|
||||
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
|
||||
"1000_monthly_responses": "1000 Respostas Mensais",
|
||||
"1_project": "1 Projeto",
|
||||
"2000_contacts": "2.000 Contatos",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
|
||||
"advanced_targeting": "Mira Avançada",
|
||||
"7500_contacts": "7.500 Contatos",
|
||||
"all_integrations": "Todas as Integrações",
|
||||
"all_surveying_features": "Todos os recursos de levantamento",
|
||||
"annually": "anualmente",
|
||||
"api_webhooks": "API e Webhooks",
|
||||
"app_surveys": "Pesquisas de App",
|
||||
"contact_us": "Fale Conosco",
|
||||
"attribute_based_targeting": "Segmentação Baseada em Atributos",
|
||||
"current": "atual",
|
||||
"current_plan": "Plano Atual",
|
||||
"current_tier_limit": "Limite Atual de Nível",
|
||||
"custom_miu_limit": "Limite MIU personalizado",
|
||||
"custom": "Personalizado e Escala",
|
||||
"custom_contacts_limit": "Limite de Contatos Personalizado",
|
||||
"custom_project_limit": "Limite de Projeto Personalizado",
|
||||
"customer_success_manager": "Gerente de Sucesso do Cliente",
|
||||
"custom_response_limit": "Limite de Resposta Personalizado",
|
||||
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
|
||||
"email_support": "Suporte por Email",
|
||||
"enterprise": "Empresa",
|
||||
"email_follow_ups": "Acompanhamentos por Email",
|
||||
"enterprise_description": "Suporte premium e limites personalizados.",
|
||||
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
|
||||
"everything_in_free": "Tudo de graça",
|
||||
"everything_in_scale": "Tudo em Escala",
|
||||
"everything_in_startup": "Tudo em Startup",
|
||||
"free": "grátis",
|
||||
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
|
||||
"get_2_months_free": "Ganhe 2 meses grátis",
|
||||
"get_in_touch": "Entre em contato",
|
||||
"hosted_in_frankfurt": "Hospedado em Frankfurt",
|
||||
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
|
||||
"link_surveys": "Link de Pesquisas (Compartilhável)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
|
||||
"manage_card_details": "Gerenciar Detalhes do Cartão",
|
||||
"manage_subscription": "Gerenciar Assinatura",
|
||||
"monthly": "mensal",
|
||||
"monthly_identified_users": "Usuários Identificados Mensalmente",
|
||||
"multi_language_surveys": "Pesquisas Multilíngues",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"priority_support": "Suporte Prioritário",
|
||||
"remove_branding": "Remover Marca",
|
||||
"say_hi": "Diz oi!",
|
||||
"scale": "escala",
|
||||
"scale_description": "Recursos avançados pra escalar seu negócio.",
|
||||
"startup": "startup",
|
||||
"startup_description": "Tudo no Grátis com recursos adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipe",
|
||||
"technical_onboarding": "Integração Técnica",
|
||||
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
|
||||
"unlimited_apps_websites": "Apps e Sites Ilimitados",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
"unlimited_projects": "Projetos Ilimitados",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
@@ -1076,6 +1064,7 @@
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.",
|
||||
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
|
||||
"delete_member_confirmation": "Membros apagados perderão acesso a todos os projetos e pesquisas da sua organização.",
|
||||
"delete_organization": "Excluir Organização",
|
||||
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
|
||||
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
|
||||
@@ -1232,8 +1221,9 @@
|
||||
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
|
||||
"copy_survey_error": "Falha ao copiar pesquisa",
|
||||
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
|
||||
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
|
||||
"copy_survey_success": "Pesquisa copiada com sucesso!",
|
||||
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.",
|
||||
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
|
||||
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
|
||||
@@ -1306,7 +1296,6 @@
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
"card_border_color": "Cor da borda do cartão",
|
||||
"card_shadow_color": "cor da sombra do cartão",
|
||||
"card_styling": "Estilização de Cartão",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicar e editar",
|
||||
@@ -1331,7 +1320,6 @@
|
||||
"change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.",
|
||||
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
|
||||
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
|
||||
"change_the_shadow_color_of_the_card": "Muda a cor da sombra do cartão.",
|
||||
"changes_saved": "Mudanças salvas.",
|
||||
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
@@ -1788,6 +1776,7 @@
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Compartilhar resultados",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"share_the_link": "Compartilha o link",
|
||||
"share_the_link_to_get_responses": "Compartilha o link pra receber respostas",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Pode agora iniciar sessão com a sua nova palavra-passe"
|
||||
}
|
||||
},
|
||||
"reset_password": "Redefinir palavra-passe"
|
||||
"reset_password": "Redefinir palavra-passe",
|
||||
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Criar uma conta",
|
||||
@@ -79,7 +80,7 @@
|
||||
},
|
||||
"signup_without_verification_success": {
|
||||
"user_successfully_created": "Utilizador criado com sucesso",
|
||||
"user_successfully_created_description": "O seu novo utilizador foi criado com sucesso. Por favor, clique no botão abaixo e inicie sessão na sua conta."
|
||||
"user_successfully_created_info": "Verificámos a existência de uma conta associada a {email}. Se não existia, criámos uma para si. Se já existia uma conta, não foram feitas alterações. Por favor, inicie sessão abaixo para continuar."
|
||||
},
|
||||
"testimonial_1": "Medimos a clareza dos nossos documentos e aprendemos com a rotatividade, tudo numa só plataforma. Ótimo produto, equipa muito responsiva!",
|
||||
"testimonial_all_features_included": "Todas as funcionalidades incluídas",
|
||||
@@ -91,11 +92,10 @@
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum email fornecido",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.",
|
||||
"please_confirm_your_email_address": "Por favor, confirme o seu endereço de email",
|
||||
"resend_verification_email": "Reenviar email de verificação",
|
||||
"verification_email_resent_successfully": "Email de verificação enviado! Por favor, verifique a sua caixa de entrada.",
|
||||
"we_sent_an_email_to": "Enviámos um email para {email}. ",
|
||||
"verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviámos um link de verificação para esse endereço. Por favor, verifique a sua caixa de entrada para completar o registo.",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
|
||||
},
|
||||
"verify": {
|
||||
@@ -135,7 +135,6 @@
|
||||
"app_survey": "Inquérito da Aplicação",
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Tem a certeza?",
|
||||
"are_you_sure_this_action_cannot_be_undone": "Tem a certeza? Esta ação não pode ser desfeita.",
|
||||
"attributes": "Atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
@@ -192,7 +191,6 @@
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
"embed": "Incorporar",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
@@ -310,7 +308,6 @@
|
||||
"project_not_found": "Projeto não encontrado",
|
||||
"project_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"projects": "Projetos",
|
||||
"projects_limit_reached": "Limite de projetos atingido",
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da pergunta",
|
||||
"questions": "Perguntas",
|
||||
@@ -318,6 +315,7 @@
|
||||
"remove": "Remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Inquérito",
|
||||
"request_pricing": "Pedido de Preços",
|
||||
"request_trial_license": "Solicitar licença de teste",
|
||||
"reset_to_default": "Repor para o padrão",
|
||||
"response": "Resposta",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_survey": "Inquérito do Website",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"yes": "Sim",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorised_to_perform_this_action": "Não está autorizado para realizar esta ação.",
|
||||
@@ -598,6 +595,7 @@
|
||||
"contact_not_found": "Nenhum contacto encontrado",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"first_name": "Primeiro Nome",
|
||||
"last_name": "Apelido",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
@@ -634,6 +632,7 @@
|
||||
"airtable_integration": "Integração com o Airtable",
|
||||
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
|
||||
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
|
||||
"airtable_logo": "logotipo Airtable",
|
||||
"connect_with_airtable": "Ligar ao Airtable",
|
||||
"link_airtable_table": "Ligar Tabela Airtable",
|
||||
"link_new_table": "Ligar nova tabela",
|
||||
@@ -701,7 +700,6 @@
|
||||
"select_a_database": "Selecionar Base de Dados",
|
||||
"select_a_field_to_map": "Selecione um campo para mapear",
|
||||
"select_a_survey_question": "Selecione uma pergunta do inquérito",
|
||||
"sync_responses_with_a_notion_database": "Sincronizar respostas com uma Base de Dados do Notion",
|
||||
"update_connection": "Reconectar Notion",
|
||||
"update_connection_tooltip": "Restabeleça a integração para incluir as bases de dados recentemente adicionadas. As suas integrações existentes permanecerão intactas."
|
||||
},
|
||||
@@ -723,6 +721,7 @@
|
||||
"slack_integration": "Integração com Slack",
|
||||
"slack_integration_description": "Enviar respostas diretamente para o Slack.",
|
||||
"slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.",
|
||||
"slack_logo": "Logótipo Slack",
|
||||
"slack_reconnect_button": "Reconectar",
|
||||
"slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack."
|
||||
},
|
||||
@@ -907,8 +906,7 @@
|
||||
"tag_already_exists": "A etiqueta já existe",
|
||||
"tag_deleted": "Etiqueta eliminada",
|
||||
"tag_updated": "Etiqueta atualizada",
|
||||
"tags_merged": "Etiquetas fundidas",
|
||||
"unique_constraint_failed_on_the_fields": "A restrição de unicidade falhou nos campos"
|
||||
"tags_merged": "Etiquetas fundidas"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "Gerir equipas",
|
||||
@@ -981,63 +979,53 @@
|
||||
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
"1500_monthly_responses": "1500 Respostas Mensais",
|
||||
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
|
||||
"1000_monthly_responses": "1000 Respostas Mensais",
|
||||
"1_project": "1 Projeto",
|
||||
"2000_contacts": "2,000 Contactos",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
|
||||
"advanced_targeting": "Segmentação Avançada",
|
||||
"7500_contacts": "7,500 Contactos",
|
||||
"all_integrations": "Todas as Integrações",
|
||||
"all_surveying_features": "Todas as funcionalidades de inquérito",
|
||||
"annually": "Anualmente",
|
||||
"api_webhooks": "API e Webhooks",
|
||||
"app_surveys": "Inquéritos da Aplicação",
|
||||
"contact_us": "Contacte-nos",
|
||||
"attribute_based_targeting": "Segmentação Baseada em Atributos",
|
||||
"current": "Atual",
|
||||
"current_plan": "Plano Atual",
|
||||
"current_tier_limit": "Limite Atual do Nível",
|
||||
"custom_miu_limit": "Limite MIU Personalizado",
|
||||
"custom": "Personalizado e Escala",
|
||||
"custom_contacts_limit": "Limite de Contactos Personalizado",
|
||||
"custom_project_limit": "Limite de Projeto Personalizado",
|
||||
"customer_success_manager": "Gestor de Sucesso do Cliente",
|
||||
"custom_response_limit": "Limite de Resposta Personalizado",
|
||||
"email_embedded_surveys": "Inquéritos Incorporados no Email",
|
||||
"email_support": "Suporte por Email",
|
||||
"enterprise": "Empresa",
|
||||
"email_follow_ups": "Acompanhamentos por Email",
|
||||
"enterprise_description": "Suporte premium e limites personalizados.",
|
||||
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
|
||||
"everything_in_free": "Tudo em Gratuito",
|
||||
"everything_in_scale": "Tudo em Escala",
|
||||
"everything_in_startup": "Tudo em Startup",
|
||||
"free": "Grátis",
|
||||
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
|
||||
"get_2_months_free": "Obtenha 2 meses grátis",
|
||||
"get_in_touch": "Entre em contacto",
|
||||
"hosted_in_frankfurt": "Hospedado em Frankfurt",
|
||||
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
|
||||
"link_surveys": "Ligar Inquéritos (Partilhável)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
|
||||
"manage_card_details": "Gerir Detalhes do Cartão",
|
||||
"manage_subscription": "Gerir Subscrição",
|
||||
"monthly": "Mensal",
|
||||
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
|
||||
"multi_language_surveys": "Inquéritos Multilingues",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"priority_support": "Suporte Prioritário",
|
||||
"remove_branding": "Remover Marca",
|
||||
"say_hi": "Diga Olá!",
|
||||
"scale": "Escala",
|
||||
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
|
||||
"startup": "Inicialização",
|
||||
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipa",
|
||||
"technical_onboarding": "Integração Técnica",
|
||||
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
|
||||
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
"unlimited_projects": "Projetos Ilimitados",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
@@ -1076,6 +1064,7 @@
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
|
||||
"customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior",
|
||||
"delete_member_confirmation": "Membros eliminados perderão acesso a todos os projetos e inquéritos da sua organização.",
|
||||
"delete_organization": "Eliminar Organização",
|
||||
"delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
|
||||
"delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:",
|
||||
@@ -1232,8 +1221,9 @@
|
||||
"copy_survey_description": "Copiar este questionário para outro ambiente",
|
||||
"copy_survey_error": "Falha ao copiar inquérito",
|
||||
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
|
||||
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
|
||||
"copy_survey_success": "Inquérito copiado com sucesso!",
|
||||
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.",
|
||||
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
|
||||
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
|
||||
@@ -1306,7 +1296,6 @@
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
"card_border_color": "Cor da borda do cartão",
|
||||
"card_shadow_color": "Cor da sombra do cartão",
|
||||
"card_styling": "Estilo do cartão",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicar e editar",
|
||||
@@ -1331,7 +1320,6 @@
|
||||
"change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito",
|
||||
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
|
||||
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
|
||||
"change_the_shadow_color_of_the_card": "Alterar a cor da sombra do cartão.",
|
||||
"changes_saved": "Alterações guardadas.",
|
||||
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
@@ -1788,6 +1776,7 @@
|
||||
"setup_instructions": "Instruções de configuração",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Partilhar resultados",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"share_the_link": "Partilhar o link",
|
||||
"share_the_link_to_get_responses": "Partilhe o link para obter respostas",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
@@ -1893,12 +1882,12 @@
|
||||
},
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
|
||||
"completed": "Este inquérito gratuito e de código aberto foi encerrado.",
|
||||
"create_your_own": "Crie o seu próprio",
|
||||
"completed": "Este inquérito está encerrado.",
|
||||
"create_your_own": "Crie o seu próprio inquérito de código aberto",
|
||||
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo",
|
||||
"just_curious": "Só por curiosidade?",
|
||||
"link_invalid": "Este inquérito só pode ser respondido por convite.",
|
||||
"paused": "Este inquérito gratuito e de código aberto está temporariamente pausado.",
|
||||
"paused": "Este inquérito está temporariamente suspenso.",
|
||||
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
|
||||
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
|
||||
"question_preview": "Pré-visualização da Pergunta",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "您現在可以使用新密碼登入"
|
||||
}
|
||||
},
|
||||
"reset_password": "重設密碼"
|
||||
"reset_password": "重設密碼",
|
||||
"reset_password_description": "您將被登出以重設您的密碼。"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "建立帳戶",
|
||||
@@ -79,7 +80,7 @@
|
||||
},
|
||||
"signup_without_verification_success": {
|
||||
"user_successfully_created": "使用者建立成功",
|
||||
"user_successfully_created_description": "您的新使用者已成功建立。請點擊下方按鈕並登入您的帳戶。"
|
||||
"user_successfully_created_info": "我們已檢查與 {email} 相關聯的帳戶。如果不存在,我們已為您建立一個。如果帳戶已存在,則未進行任何更改。請在下方登入以繼續。"
|
||||
},
|
||||
"testimonial_1": "我們在同一個平台上測量文件的清晰度,並從客戶流失中學習。很棒的產品,團隊反應非常迅速!",
|
||||
"testimonial_all_features_included": "包含所有功能",
|
||||
@@ -91,11 +92,10 @@
|
||||
"invalid_token": "無效的權杖 ☹️",
|
||||
"new_email_verification_success": "如果地址有效,驗證電子郵件已發送。",
|
||||
"no_email_provided": "未提供電子郵件",
|
||||
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
|
||||
"please_confirm_your_email_address": "請確認您的電子郵件地址",
|
||||
"resend_verification_email": "重新發送驗證電子郵件",
|
||||
"verification_email_resent_successfully": "驗證電子郵件已發送!請檢查您的收件箱。",
|
||||
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
|
||||
"verification_email_successfully_sent_info": "如果有一個帳戶與 {email} 相關聯,我們已發送驗證連結至該地址。請檢查您的收件箱以完成註冊。",
|
||||
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
|
||||
},
|
||||
"verify": {
|
||||
@@ -135,7 +135,6 @@
|
||||
"app_survey": "應用程式問卷",
|
||||
"apply_filters": "套用篩選器",
|
||||
"are_you_sure": "您確定嗎?",
|
||||
"are_you_sure_this_action_cannot_be_undone": "您確定嗎?此操作無法復原。",
|
||||
"attributes": "屬性",
|
||||
"avatar": "頭像",
|
||||
"back": "返回",
|
||||
@@ -192,7 +191,6 @@
|
||||
"e_commerce": "電子商務",
|
||||
"edit": "編輯",
|
||||
"email": "電子郵件",
|
||||
"embed": "嵌入",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
@@ -310,7 +308,6 @@
|
||||
"project_not_found": "找不到專案",
|
||||
"project_permission_not_found": "找不到專案權限",
|
||||
"projects": "專案",
|
||||
"projects_limit_reached": "已達到專案上限",
|
||||
"question": "問題",
|
||||
"question_id": "問題 ID",
|
||||
"questions": "問題",
|
||||
@@ -318,6 +315,7 @@
|
||||
"remove": "移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
"report_survey": "報告問卷",
|
||||
"request_pricing": "請求定價",
|
||||
"request_trial_license": "請求試用授權",
|
||||
"reset_to_default": "重設為預設值",
|
||||
"response": "回應",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_survey": "網站問卷",
|
||||
"weekly_summary": "每週摘要",
|
||||
"welcome_card": "歡迎卡片",
|
||||
"yes": "是",
|
||||
"you": "您",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
"you_are_not_authorised_to_perform_this_action": "您未獲授權執行此操作。",
|
||||
@@ -598,6 +595,7 @@
|
||||
"contact_not_found": "找不到此聯絡人",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||
"first_name": "名字",
|
||||
"last_name": "姓氏",
|
||||
"no_responses_found": "找不到回應",
|
||||
@@ -634,6 +632,7 @@
|
||||
"airtable_integration": "Airtable 整合",
|
||||
"airtable_integration_description": "直接與 Airtable 同步回應。",
|
||||
"airtable_integration_is_not_configured": "尚未設定 Airtable 整合",
|
||||
"airtable_logo": "Airtable 標誌",
|
||||
"connect_with_airtable": "連線 Airtable",
|
||||
"link_airtable_table": "連結 Airtable 表格",
|
||||
"link_new_table": "連結新表格",
|
||||
@@ -701,7 +700,6 @@
|
||||
"select_a_database": "選取資料庫",
|
||||
"select_a_field_to_map": "選取要對應的欄位",
|
||||
"select_a_survey_question": "選取問卷問題",
|
||||
"sync_responses_with_a_notion_database": "與 Notion 資料庫同步回應",
|
||||
"update_connection": "重新連線 Notion",
|
||||
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
|
||||
},
|
||||
@@ -723,6 +721,7 @@
|
||||
"slack_integration": "Slack 整合",
|
||||
"slack_integration_description": "直接將回應傳送至 Slack。",
|
||||
"slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。",
|
||||
"slack_logo": "Slack 標誌",
|
||||
"slack_reconnect_button": "重新連線",
|
||||
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
|
||||
},
|
||||
@@ -907,8 +906,7 @@
|
||||
"tag_already_exists": "標籤已存在",
|
||||
"tag_deleted": "標籤已刪除",
|
||||
"tag_updated": "標籤已更新",
|
||||
"tags_merged": "標籤已合併",
|
||||
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
|
||||
"tags_merged": "標籤已合併"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "管理團隊",
|
||||
@@ -981,63 +979,53 @@
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 個每月回應",
|
||||
"1500_monthly_responses": "1500 個每月回應",
|
||||
"2000_monthly_identified_users": "2000 個每月識別使用者",
|
||||
"30000_monthly_identified_users": "30000 個每月識別使用者",
|
||||
"1000_monthly_responses": "1000 個每月回應",
|
||||
"1_project": "1 個專案",
|
||||
"2000_contacts": "2000 個聯絡人",
|
||||
"3_projects": "3 個專案",
|
||||
"5000_monthly_responses": "5000 個每月回應",
|
||||
"5_projects": "5 個專案",
|
||||
"7500_monthly_identified_users": "7500 個每月識別使用者",
|
||||
"advanced_targeting": "進階目標設定",
|
||||
"7500_contacts": "7500 個聯絡人",
|
||||
"all_integrations": "所有整合",
|
||||
"all_surveying_features": "所有調查功能",
|
||||
"annually": "每年",
|
||||
"api_webhooks": "API 和 Webhook",
|
||||
"app_surveys": "應用程式問卷",
|
||||
"contact_us": "聯絡我們",
|
||||
"attribute_based_targeting": "基於屬性的定位",
|
||||
"current": "目前",
|
||||
"current_plan": "目前方案",
|
||||
"current_tier_limit": "目前層級限制",
|
||||
"custom_miu_limit": "自訂 MIU 上限",
|
||||
"custom": "自訂 & 規模",
|
||||
"custom_contacts_limit": "自訂聯絡人上限",
|
||||
"custom_project_limit": "自訂專案上限",
|
||||
"customer_success_manager": "客戶成功經理",
|
||||
"custom_response_limit": "自訂回應上限",
|
||||
"email_embedded_surveys": "電子郵件嵌入式問卷",
|
||||
"email_support": "電子郵件支援",
|
||||
"enterprise": "企業版",
|
||||
"email_follow_ups": "電子郵件後續追蹤",
|
||||
"enterprise_description": "頂級支援和自訂限制。",
|
||||
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
|
||||
"everything_in_free": "免費方案中的所有功能",
|
||||
"everything_in_scale": "進階方案中的所有功能",
|
||||
"everything_in_startup": "啟動方案中的所有功能",
|
||||
"free": "免費",
|
||||
"free_description": "無限問卷、團隊成員等。",
|
||||
"get_2_months_free": "免費獲得 2 個月",
|
||||
"get_in_touch": "取得聯繫",
|
||||
"hosted_in_frankfurt": "託管在 Frankfurt",
|
||||
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
|
||||
"link_surveys": "連結問卷(可分享)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
|
||||
"manage_card_details": "管理卡片詳細資料",
|
||||
"manage_subscription": "管理訂閱",
|
||||
"monthly": "每月",
|
||||
"monthly_identified_users": "每月識別使用者",
|
||||
"multi_language_surveys": "多語言問卷",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_upgraded_successfully": "方案已成功升級",
|
||||
"premium_support_with_slas": "具有 SLA 的頂級支援",
|
||||
"priority_support": "優先支援",
|
||||
"remove_branding": "移除品牌",
|
||||
"say_hi": "打個招呼!",
|
||||
"scale": "進階版",
|
||||
"scale_description": "用於擴展業務的進階功能。",
|
||||
"startup": "啟動版",
|
||||
"startup_description": "免費方案中的所有功能以及其他功能。",
|
||||
"switch_plan": "切換方案",
|
||||
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
|
||||
"team_access_roles": "團隊存取角色",
|
||||
"technical_onboarding": "技術新手上路",
|
||||
"unable_to_upgrade_plan": "無法升級方案",
|
||||
"unlimited_apps_websites": "無限應用程式和網站",
|
||||
"unlimited_miu": "無限 MIU",
|
||||
"unlimited_projects": "無限專案",
|
||||
"unlimited_responses": "無限回應",
|
||||
@@ -1076,6 +1064,7 @@
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_new_organization_description": "建立新組織以處理一組不同的專案。",
|
||||
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
|
||||
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
|
||||
"delete_organization": "刪除組織",
|
||||
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性",
|
||||
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
|
||||
@@ -1232,8 +1221,9 @@
|
||||
"copy_survey_description": "將此問卷複製到另一個環境",
|
||||
"copy_survey_error": "無法複製問卷",
|
||||
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
|
||||
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
|
||||
"copy_survey_success": "問卷已成功複製!",
|
||||
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。",
|
||||
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
|
||||
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
|
||||
@@ -1306,7 +1296,6 @@
|
||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||
"card_background_color": "卡片背景顏色",
|
||||
"card_border_color": "卡片邊框顏色",
|
||||
"card_shadow_color": "卡片陰影顏色",
|
||||
"card_styling": "卡片樣式設定",
|
||||
"casual": "隨意",
|
||||
"caution_edit_duplicate": "複製 & 編輯",
|
||||
@@ -1331,7 +1320,6 @@
|
||||
"change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。",
|
||||
"change_the_placement_of_this_survey": "變更此問卷的位置。",
|
||||
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
|
||||
"change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。",
|
||||
"changes_saved": "已儲存變更。",
|
||||
"character_limit_toggle_description": "限制答案的長度或短度。",
|
||||
"character_limit_toggle_title": "新增字元限制",
|
||||
@@ -1788,6 +1776,7 @@
|
||||
"setup_instructions": "設定說明",
|
||||
"setup_integrations": "設定整合",
|
||||
"share_results": "分享結果",
|
||||
"share_survey": "分享問卷",
|
||||
"share_the_link": "分享連結",
|
||||
"share_the_link_to_get_responses": "分享連結以取得回應",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
|
||||
@@ -7,17 +7,19 @@ import {
|
||||
syncUserIdentificationLimiter,
|
||||
verifyEmailLimiter,
|
||||
} from "@/app/middleware/bucket";
|
||||
import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middleware/domain-utils";
|
||||
import {
|
||||
isAuthProtectedRoute,
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isRouteAllowedForDomain,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
|
||||
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { isValidCallbackUrl } from "@/lib/utils/url";
|
||||
import { logApiErrorEdge } from "@/modules/api/v2/lib/utils-edge";
|
||||
@@ -69,33 +71,38 @@ const applyRateLimiting = async (request: NextRequest, ip: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSurveyDomain = (request: NextRequest): Response | null => {
|
||||
/**
|
||||
* Handle domain-aware routing based on PUBLIC_URL and WEBAPP_URL
|
||||
*/
|
||||
const handleDomainAwareRouting = (request: NextRequest): Response | null => {
|
||||
try {
|
||||
if (!SURVEY_URL) return null;
|
||||
const publicDomainConfigured = isPublicDomainConfigured();
|
||||
|
||||
const host = request.headers.get("host") || "";
|
||||
const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : "";
|
||||
if (host !== surveyDomain) return null;
|
||||
// When PUBLIC_URL is not configured, admin domain allows all routes (backward compatibility)
|
||||
if (!publicDomainConfigured) return null;
|
||||
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const isPublicDomain = isRequestFromPublicDomain(request);
|
||||
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// Check if the route is allowed for the current domain
|
||||
const isAllowed = isRouteAllowedForDomain(pathname, isPublicDomain);
|
||||
|
||||
if (!isAllowed) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
return null; // Allow the request to continue
|
||||
} catch (error) {
|
||||
logger.error(error, "Error handling survey domain");
|
||||
logger.error(error, "Error handling domain-aware routing");
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
const isSurveyRoute = (request: NextRequest) => {
|
||||
return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/");
|
||||
};
|
||||
|
||||
export const middleware = async (originalRequest: NextRequest) => {
|
||||
if (isSurveyRoute(originalRequest)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Handle survey domain routing.
|
||||
const surveyResponse = handleSurveyDomain(originalRequest);
|
||||
if (surveyResponse) return surveyResponse;
|
||||
// Handle domain-aware routing first
|
||||
const domainResponse = handleDomainAwareRouting(originalRequest);
|
||||
if (domainResponse) return domainResponse;
|
||||
|
||||
// Create a new Request object to override headers and add a unique request ID header
|
||||
const request = new NextRequest(originalRequest, {
|
||||
@@ -142,6 +149,6 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware
|
||||
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -78,6 +79,11 @@ describe("DeleteAccountModal", () => {
|
||||
.spyOn(actions, "deleteUserAction")
|
||||
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
writable: true,
|
||||
value: { removeItem: vi.fn() },
|
||||
});
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
@@ -94,6 +100,8 @@ describe("DeleteAccountModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
const input = screen.getByTestId("deleteAccountConfirmation");
|
||||
fireEvent.change(input, { target: { value: mockUser.email } });
|
||||
|
||||
@@ -106,6 +114,7 @@ describe("DeleteAccountModal", () => {
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
@@ -116,6 +125,11 @@ describe("DeleteAccountModal", () => {
|
||||
.spyOn(actions, "deleteUserAction")
|
||||
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
writable: true,
|
||||
value: { removeItem: vi.fn() },
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { replace: vi.fn() },
|
||||
@@ -137,12 +151,15 @@ describe("DeleteAccountModal", () => {
|
||||
const form = screen.getByTestId("deleteAccountForm");
|
||||
fireEvent.submit(form);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -38,6 +39,8 @@ export const DeleteAccountModal = ({
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
reason: "account_deletion",
|
||||
|
||||
@@ -61,20 +61,20 @@ describe("RatingSmiley", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const activeClass = "fill-rating-fill";
|
||||
const activeClass = "bg-rating-fill";
|
||||
|
||||
// Test branch: range === 10 => iconsIdx = [0,1,2,...,9]
|
||||
test("renders correct icon for range 10 when active", () => {
|
||||
// For idx 0, iconsIdx[0] === 0, which corresponds to TiredFace.
|
||||
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={10} addColors={true} />);
|
||||
const icon = getByTestId("TiredFace");
|
||||
const icon = getByTestId("tired");
|
||||
expect(icon).toBeDefined();
|
||||
expect(icon.className).toContain(activeClass);
|
||||
});
|
||||
|
||||
test("renders correct icon for range 10 when inactive", () => {
|
||||
const { getByTestId } = render(<RatingSmiley active={false} idx={0} range={10} />);
|
||||
const icon = getByTestId("TiredFace");
|
||||
const icon = getByTestId("tired");
|
||||
expect(icon).toBeDefined();
|
||||
expect(icon.className).toContain("fill-none");
|
||||
});
|
||||
@@ -83,7 +83,7 @@ describe("RatingSmiley", () => {
|
||||
test("renders correct icon for range 7 when active", () => {
|
||||
// For idx 0, iconsIdx[0] === 1, which corresponds to WearyFace.
|
||||
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={7} addColors={true} />);
|
||||
const icon = getByTestId("WearyFace");
|
||||
const icon = getByTestId("weary");
|
||||
expect(icon).toBeDefined();
|
||||
expect(icon.className).toContain(activeClass);
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe("RatingSmiley", () => {
|
||||
test("renders correct icon for range 5 when active", () => {
|
||||
// For idx 0, iconsIdx[0] === 3, which corresponds to FrowningFace.
|
||||
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={5} addColors={true} />);
|
||||
const icon = getByTestId("FrowningFace");
|
||||
const icon = getByTestId("frowning");
|
||||
expect(icon).toBeDefined();
|
||||
expect(icon.className).toContain(activeClass);
|
||||
});
|
||||
@@ -101,7 +101,7 @@ describe("RatingSmiley", () => {
|
||||
test("renders correct icon for range 4 when active", () => {
|
||||
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
|
||||
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={4} addColors={true} />);
|
||||
const icon = getByTestId("ConfusedFace");
|
||||
const icon = getByTestId("confused");
|
||||
expect(icon).toBeDefined();
|
||||
expect(icon.className).toContain(activeClass);
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe("RatingSmiley", () => {
|
||||
test("renders correct icon for range 3 when active", () => {
|
||||
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
|
||||
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={3} addColors={true} />);
|
||||
const icon = getByTestId("ConfusedFace");
|
||||
const icon = getByTestId("confused");
|
||||
expect(icon).toBeDefined();
|
||||
expect(icon.className).toContain(activeClass);
|
||||
});
|
||||
|
||||
@@ -1,93 +1,89 @@
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
ConfusedFace,
|
||||
FrowningFace,
|
||||
GrinningFaceWithSmilingEyes,
|
||||
GrinningSquintingFace,
|
||||
NeutralFace,
|
||||
PerseveringFace,
|
||||
SlightlySmilingFace,
|
||||
SmilingFaceWithSmilingEyes,
|
||||
TiredFace,
|
||||
WearyFace,
|
||||
} from "../SingleResponseCard/components/Smileys";
|
||||
|
||||
interface RatingSmileyProps {
|
||||
active: boolean;
|
||||
idx: number;
|
||||
range: number;
|
||||
addColors?: boolean;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
const getSmileyColor = (range: number, idx: number) => {
|
||||
if (range > 5) {
|
||||
if (range - idx < 3) return "fill-emerald-100";
|
||||
if (range - idx < 5) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
if (range - idx < 3) return "bg-emerald-100";
|
||||
if (range - idx < 5) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
} else if (range < 5) {
|
||||
if (range - idx < 2) return "fill-emerald-100";
|
||||
if (range - idx < 3) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
if (range - idx < 2) return "bg-emerald-100";
|
||||
if (range - idx < 3) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
} else {
|
||||
if (range - idx < 3) return "fill-emerald-100";
|
||||
if (range - idx < 4) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
if (range - idx < 3) return "bg-emerald-100";
|
||||
if (range - idx < 4) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
}
|
||||
};
|
||||
|
||||
const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean, addColors: boolean) => {
|
||||
const activeColor = "fill-rating-fill";
|
||||
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
|
||||
// Helper function to get smiley image URL based on index and range
|
||||
const getSmiley = (
|
||||
iconIdx: number,
|
||||
idx: number,
|
||||
range: number,
|
||||
active: boolean,
|
||||
addColors: boolean,
|
||||
baseUrl?: string
|
||||
): JSX.Element => {
|
||||
const activeColor = "bg-rating-fill";
|
||||
const inactiveColor = addColors ? getSmileyColor(range, idx) : "bg-fill-none";
|
||||
|
||||
const icons = [
|
||||
<TiredFace className={active ? activeColor : inactiveColor} data-testid="TiredFace" key="tired-face" />,
|
||||
<WearyFace className={active ? activeColor : inactiveColor} data-testid="WearyFace" key="weary-face" />,
|
||||
<PerseveringFace
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="PerseveringFace"
|
||||
key="perserving-face"
|
||||
/>,
|
||||
<FrowningFace
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="FrowningFace"
|
||||
key="frowning-face"
|
||||
/>,
|
||||
<ConfusedFace
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="ConfusedFace"
|
||||
key="confused-face"
|
||||
/>,
|
||||
<NeutralFace
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="NeutralFace"
|
||||
key="neutral-face"
|
||||
/>,
|
||||
<SlightlySmilingFace
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="SlightlySmilingFace"
|
||||
key="slightly-smiling-face"
|
||||
/>,
|
||||
<SmilingFaceWithSmilingEyes
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="SmilingFaceWithSmilingEyes"
|
||||
key="smiling-face-with-smiling-eyes"
|
||||
/>,
|
||||
<GrinningFaceWithSmilingEyes
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="GrinningFaceWithSmilingEyes"
|
||||
key="grinning-face-with-smiling-eyes"
|
||||
/>,
|
||||
<GrinningSquintingFace
|
||||
className={active ? activeColor : inactiveColor}
|
||||
data-testid="GrinningSquintingFace"
|
||||
key="grinning-squinting-face"
|
||||
/>,
|
||||
const faceIcons = [
|
||||
"tired",
|
||||
"weary",
|
||||
"persevering",
|
||||
"frowning",
|
||||
"confused",
|
||||
"neutral",
|
||||
"slightly-smiling",
|
||||
"smiling-face-with-smiling-eyes",
|
||||
"grinning-face-with-smiling-eyes",
|
||||
"grinning-squinting",
|
||||
];
|
||||
|
||||
return icons[iconIdx];
|
||||
const icon = (
|
||||
<img
|
||||
data-testid={faceIcons[iconIdx]}
|
||||
src={
|
||||
baseUrl
|
||||
? `${baseUrl}/smiley-icons/${faceIcons[iconIdx]}-face.png`
|
||||
: `/smiley-icons/${faceIcons[iconIdx]}-face.png`
|
||||
}
|
||||
alt={faceIcons[iconIdx]}
|
||||
width={24}
|
||||
height={24}
|
||||
className={`${active ? activeColor : inactiveColor} rounded-full`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<table style={{ width: "48px", height: "48px" }}>
|
||||
{" "}
|
||||
{/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */}
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
{icon}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
export const RatingSmiley = ({ active, idx, range, addColors = false }: RatingSmileyProps): JSX.Element => {
|
||||
export const RatingSmiley = ({
|
||||
active,
|
||||
idx,
|
||||
range,
|
||||
addColors = false,
|
||||
baseUrl,
|
||||
}: RatingSmileyProps): JSX.Element => {
|
||||
let iconsIdx: number[] = [];
|
||||
if (range === 10) iconsIdx = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
else if (range === 7) iconsIdx = [1, 3, 4, 5, 6, 8, 9];
|
||||
@@ -96,5 +92,5 @@ export const RatingSmiley = ({ active, idx, range, addColors = false }: RatingSm
|
||||
else if (range === 4) iconsIdx = [4, 5, 6, 7];
|
||||
else if (range === 3) iconsIdx = [4, 5, 7];
|
||||
|
||||
return getSmiley(iconsIdx[idx], idx, range, active, addColors);
|
||||
return getSmiley(iconsIdx[idx], idx, range, active, addColors, baseUrl);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ const dummySurvey = {
|
||||
type: "link",
|
||||
status: "completed",
|
||||
} as any;
|
||||
const dummySurveyDomain = "http://dummy.com";
|
||||
const dummyPublicDomain = "http://dummy.com";
|
||||
const dummyLocale = "en-US";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -93,7 +93,7 @@ describe("ShareSurveyLink", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
surveyDomain={dummySurveyDomain}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl=""
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
@@ -103,7 +103,7 @@ describe("ShareSurveyLink", () => {
|
||||
expect(setSurveyUrl).toHaveBeenCalled();
|
||||
});
|
||||
const url = setSurveyUrl.mock.calls[0][0];
|
||||
expect(url).toContain(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
|
||||
expect(url).toContain(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
|
||||
expect(url).not.toContain("lang=");
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("ShareSurveyLink", () => {
|
||||
const DummyWrapper = () => (
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
surveyDomain={dummySurveyDomain}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl="initial"
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale="fr-FR"
|
||||
@@ -130,12 +130,12 @@ describe("ShareSurveyLink", () => {
|
||||
test("preview button opens new window with preview query", async () => {
|
||||
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
|
||||
|
||||
const setSurveyUrl = vi.fn().mockReturnValue(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
|
||||
const setSurveyUrl = vi.fn().mockReturnValue(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
surveyDomain={dummySurveyDomain}
|
||||
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
@@ -156,11 +156,11 @@ describe("ShareSurveyLink", () => {
|
||||
vi.mocked(copySurveyLink).mockImplementation((url: string, newId: string) => `${url}?suId=${newId}`);
|
||||
|
||||
const setSurveyUrl = vi.fn();
|
||||
const surveyUrl = `${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`;
|
||||
const surveyUrl = `${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`;
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
surveyDomain={dummySurveyDomain}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
@@ -185,8 +185,8 @@ describe("ShareSurveyLink", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
surveyDomain={dummySurveyDomain}
|
||||
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
@@ -205,8 +205,8 @@ describe("ShareSurveyLink", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
surveyDomain={dummySurveyDomain}
|
||||
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
@@ -227,7 +227,7 @@ describe("ShareSurveyLink", () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={dummySurvey}
|
||||
surveyDomain={dummySurveyDomain}
|
||||
publicDomain={dummyPublicDomain}
|
||||
surveyUrl=""
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={dummyLocale}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
|
||||
|
||||
interface ShareSurveyLinkProps {
|
||||
survey: TSurvey;
|
||||
surveyDomain: string;
|
||||
publicDomain: string;
|
||||
surveyUrl: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
@@ -24,7 +24,7 @@ interface ShareSurveyLinkProps {
|
||||
export const ShareSurveyLink = ({
|
||||
survey,
|
||||
surveyUrl,
|
||||
surveyDomain,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: ShareSurveyLinkProps) => {
|
||||
@@ -34,7 +34,7 @@ export const ShareSurveyLink = ({
|
||||
useEffect(() => {
|
||||
const fetchSurveyUrl = async () => {
|
||||
try {
|
||||
const url = await getSurveyUrl(survey, surveyDomain, language);
|
||||
const url = await getSurveyUrl(survey, publicDomain, language);
|
||||
setSurveyUrl(url);
|
||||
} catch (error) {
|
||||
const errorMessage = getFormattedErrorMessage(error);
|
||||
@@ -42,11 +42,11 @@ export const ShareSurveyLink = ({
|
||||
}
|
||||
};
|
||||
fetchSurveyUrl();
|
||||
}, [survey, language, surveyDomain, setSurveyUrl]);
|
||||
}, [survey, language, publicDomain, setSurveyUrl]);
|
||||
|
||||
const generateNewSingleUseLink = async () => {
|
||||
try {
|
||||
const newUrl = await getSurveyUrl(survey, surveyDomain, language);
|
||||
const newUrl = await getSurveyUrl(survey, publicDomain, language);
|
||||
setSurveyUrl(newUrl);
|
||||
toast.success(t("environments.surveys.new_single_use_link_generated"));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
ConfusedFace,
|
||||
FrowningFace,
|
||||
GrinningFaceWithSmilingEyes,
|
||||
GrinningSquintingFace,
|
||||
NeutralFace,
|
||||
PerseveringFace,
|
||||
SlightlySmilingFace,
|
||||
SmilingFaceWithSmilingEyes,
|
||||
TiredFace,
|
||||
WearyFace,
|
||||
} from "./Smileys";
|
||||
|
||||
const checkSvg = (Component: React.FC<React.SVGProps<SVGElement>>) => {
|
||||
const { container } = render(<Component />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svg).toHaveAttribute("viewBox", "0 0 72 72");
|
||||
expect(svg).toHaveAttribute("width", "36");
|
||||
expect(svg).toHaveAttribute("height", "36");
|
||||
};
|
||||
|
||||
describe("Smileys", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders TiredFace", () => {
|
||||
checkSvg(TiredFace);
|
||||
});
|
||||
test("renders WearyFace", () => {
|
||||
checkSvg(WearyFace);
|
||||
});
|
||||
test("renders PerseveringFace", () => {
|
||||
checkSvg(PerseveringFace);
|
||||
});
|
||||
test("renders FrowningFace", () => {
|
||||
checkSvg(FrowningFace);
|
||||
});
|
||||
test("renders ConfusedFace", () => {
|
||||
checkSvg(ConfusedFace);
|
||||
});
|
||||
test("renders NeutralFace", () => {
|
||||
checkSvg(NeutralFace);
|
||||
});
|
||||
test("renders SlightlySmilingFace", () => {
|
||||
checkSvg(SlightlySmilingFace);
|
||||
});
|
||||
test("renders SmilingFaceWithSmilingEyes", () => {
|
||||
checkSvg(SmilingFaceWithSmilingEyes);
|
||||
});
|
||||
test("renders GrinningFaceWithSmilingEyes", () => {
|
||||
checkSvg(GrinningFaceWithSmilingEyes);
|
||||
});
|
||||
test("renders GrinningSquintingFace", () => {
|
||||
checkSvg(GrinningSquintingFace);
|
||||
});
|
||||
});
|
||||
@@ -1,462 +0,0 @@
|
||||
export const TiredFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m21.88 23.92c5.102-0.06134 7.273-1.882 8.383-3.346"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m46.24 47.56c0-2.592-2.867-7.121-10.25-6.93-6.974 0.1812-10.22 4.518-10.22 7.111s4.271-1.611 10.05-1.492c6.317 0.13 10.43 3.903 10.43 1.311z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m23.16 28.47c5.215 1.438 5.603 0.9096 8.204 1.207 1.068 0.1221-2.03 2.67-7.282 4.397"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m50.12 23.92c-5.102-0.06134-7.273-1.882-8.383-3.346"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m48.84 28.47c-5.215 1.438-5.603 0.9096-8.204 1.207-1.068 0.1221 2.03 2.67 7.282 4.397"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const WearyFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m22.88 23.92c5.102-0.06134 7.273-1.882 8.383-3.346"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m46.24 47.56c0-2.592-2.867-7.121-10.25-6.93-6.974 0.1812-10.22 4.518-10.22 7.111s4.271-1.611 10.05-1.492c6.317 0.13 10.43 3.903 10.43 1.311z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m49.12 23.92c-5.102-0.06134-7.273-1.882-8.383-3.346"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m48.24 30.51c-6.199 1.47-7.079 1.059-8.868-1.961"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="m23.76 30.51c6.199 1.47 7.079 1.059 8.868-1.961"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const PerseveringFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<line
|
||||
x1="44.5361"
|
||||
x2="50.9214"
|
||||
y1="21.4389"
|
||||
y2="24.7158"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1="26.9214"
|
||||
x2="20.5361"
|
||||
y1="21.4389"
|
||||
y2="24.7158"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M24,28c2.3334,1.3333,4.6666,2.6667,7,4c-2.3334,1.3333-4.6666,2.6667-7,4"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M48,28c-2.3334,1.3333-4.6666,2.6667-7,4c2.3334,1.3333,4.6666,2.6667,7,4"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M28,51c0.2704-0.3562,1-8,8.4211-8.0038C43,42.9929,43.6499,50.5372,44,51C38.6667,51,33.3333,51,28,51z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const FrowningFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M26.5,48c1.8768-3.8326,5.8239-6.1965,10-6c3.8343,0.1804,7.2926,2.4926,9,6"
|
||||
/>
|
||||
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
|
||||
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfusedFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="m44.7 43.92c-6.328-1.736-11.41-0.906-17.4 1.902"
|
||||
/>
|
||||
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
|
||||
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const NeutralFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<line
|
||||
x1="27"
|
||||
x2="45"
|
||||
y1="43"
|
||||
y2="43"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
|
||||
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const SlightlySmilingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M45.8149,44.9293 c-2.8995,1.6362-6.2482,2.5699-9.8149,2.5699s-6.9153-0.9336-9.8149-2.5699"
|
||||
/>
|
||||
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
|
||||
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const SmilingFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M45.8147,45.2268a15.4294,15.4294,0,0,1-19.6294,0"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M31.6941,33.4036a4.7262,4.7262,0,0,0-8.6382,0"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M48.9441,33.4036a4.7262,4.7262,0,0,0-8.6382,0"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GrinningFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M50.595,41.64a11.5554,11.5554,0,0,1-.87,4.49c-12.49,3.03-25.43.34-27.49-.13a11.4347,11.4347,0,0,1-.83-4.36h.11s14.8,3.59,28.89.07Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M49.7251,46.13c-1.79,4.27-6.35,7.23-13.69,7.23-7.41,0-12.03-3.03-13.8-7.36C24.2951,46.47,37.235,49.16,49.7251,46.13Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M31.6941,32.4036a4.7262,4.7262,0,0,0-8.6382,0"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="2"
|
||||
d="M48.9441,32.4036a4.7262,4.7262,0,0,0-8.6382,0"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GrinningSquintingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r="23"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
{...props}
|
||||
/>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
points="25.168 27.413 31.755 31.427 25.168 35.165"
|
||||
/>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
points="46.832 27.413 40.245 31.427 46.832 35.165"
|
||||
/>
|
||||
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M50.595,41.64a11.5554,11.5554,0,0,1-.87,4.49c-12.49,3.03-25.43.34-27.49-.13a11.4347,11.4347,0,0,1-.83-4.36h.11s14.8,3.59,28.89.07Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M49.7251,46.13c-1.79,4.27-6.35,7.23-13.69,7.23-7.41,0-12.03-3.03-13.8-7.36C24.2951,46.47,37.235,49.16,49.7251,46.13Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -32,10 +32,10 @@ export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
|
||||
|
||||
export const getSurveyUrl = async (
|
||||
survey: TSurvey,
|
||||
surveyDomain: string,
|
||||
publicDomain: string,
|
||||
language: string
|
||||
): Promise<string> => {
|
||||
let url = `${surveyDomain}/s/${survey.id}`;
|
||||
let url = `${publicDomain}/s/${survey.id}`;
|
||||
const queryParams: string[] = [];
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
|
||||
@@ -29,6 +29,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getEmailFromEmailToken } from "@/lib/jwt";
|
||||
import { SignupWithoutVerificationSuccessPage } from "@/modules/auth/signup-without-verification-success/page";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
T: ({ keyName, params }) => {
|
||||
if (params && params.email) {
|
||||
return `${keyName} ${params.email}`;
|
||||
}
|
||||
return keyName;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
INTERCOM_SECRET_KEY: "test-secret-key",
|
||||
IS_INTERCOM_CONFIGURED: true,
|
||||
INTERCOM_APP_ID: "test-app-id",
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
POSTHOG_API_HOST: "test-posthog-api-host",
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AVAILABLE_LOCALES: ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"],
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/back-to-login-button", () => ({
|
||||
BackToLoginButton: () => <div>Mocked BackToLoginButton</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/form-wrapper", () => ({
|
||||
FormWrapper: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
getEmailFromEmailToken: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SignupWithoutVerificationSuccessPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("renders the success page correctly", async () => {
|
||||
vi.mocked(getEmailFromEmailToken).mockReturnValue("test@example.com");
|
||||
|
||||
const Page = await SignupWithoutVerificationSuccessPage({ searchParams: { token: "test-token" } });
|
||||
render(Page);
|
||||
|
||||
expect(
|
||||
screen.getByText("auth.signup_without_verification_success.user_successfully_created")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"auth.signup_without_verification_success.user_successfully_created_info test@example.com"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Mocked BackToLoginButton")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,23 @@
|
||||
import { getEmailFromEmailToken } from "@/lib/jwt";
|
||||
import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button";
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { T, getTranslate } from "@/tolgee/server";
|
||||
|
||||
export const SignupWithoutVerificationSuccessPage = async () => {
|
||||
export const SignupWithoutVerificationSuccessPage = async ({ searchParams }) => {
|
||||
const t = await getTranslate();
|
||||
const { token } = await searchParams;
|
||||
const email = getEmailFromEmailToken(token);
|
||||
|
||||
return (
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created_description")}
|
||||
<T
|
||||
keyName="auth.signup_without_verification_success.user_successfully_created_info"
|
||||
params={{ email, span: <span /> }}
|
||||
/>
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
|
||||
@@ -15,8 +15,18 @@ import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { UnknownError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
|
||||
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
|
||||
const ZCreatedUser = ZUser.pick({
|
||||
name: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
id: true,
|
||||
notificationSettings: true,
|
||||
});
|
||||
|
||||
type TCreatedUser = z.infer<typeof ZCreatedUser>;
|
||||
|
||||
const ZCreateUserAction = z.object({
|
||||
name: ZUserName,
|
||||
@@ -34,100 +44,166 @@ const ZCreateUserAction = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
async function verifyTurnstileIfConfigured(
|
||||
turnstileToken: string | undefined,
|
||||
email: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
if (!IS_TURNSTILE_CONFIGURED) return;
|
||||
|
||||
if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
|
||||
captureFailedSignup(email, name);
|
||||
throw new UnknownError("Server configuration error");
|
||||
}
|
||||
|
||||
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
|
||||
if (!isHuman) {
|
||||
captureFailedSignup(email, name);
|
||||
throw new UnknownError("reCAPTCHA verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserSafely(
|
||||
email: string,
|
||||
name: string,
|
||||
hashedPassword: string,
|
||||
userLocale: z.infer<typeof ZUserLocale> | undefined
|
||||
): Promise<{ user: TCreatedUser | undefined; userAlreadyExisted: boolean }> {
|
||||
let user: TCreatedUser | undefined = undefined;
|
||||
let userAlreadyExisted = false;
|
||||
|
||||
try {
|
||||
user = await createUser({
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
password: hashedPassword,
|
||||
locale: userLocale,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
userAlreadyExisted = true;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { user, userAlreadyExisted };
|
||||
}
|
||||
|
||||
async function handleInviteAcceptance(
|
||||
ctx: ActionClientCtx,
|
||||
inviteToken: string,
|
||||
user: TCreatedUser
|
||||
): Promise<void> {
|
||||
const inviteTokenData = verifyInviteToken(inviteToken);
|
||||
const invite = await getInvite(inviteTokenData.inviteId);
|
||||
|
||||
if (!invite) {
|
||||
throw new Error("Invalid invite ID");
|
||||
}
|
||||
ctx.auditLoggingCtx.organizationId = invite.organizationId;
|
||||
|
||||
await createMembership(invite.organizationId, user.id, {
|
||||
accepted: true,
|
||||
role: invite.role,
|
||||
});
|
||||
|
||||
if (invite.teamIds) {
|
||||
await createTeamMembership(
|
||||
{
|
||||
organizationId: invite.organizationId,
|
||||
role: invite.role,
|
||||
teamIds: invite.teamIds,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [invite.organizationId],
|
||||
},
|
||||
});
|
||||
|
||||
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
|
||||
await deleteInvite(invite.id);
|
||||
}
|
||||
|
||||
async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUser): Promise<void> {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) return;
|
||||
|
||||
const organization = await createOrganization({ name: `${user.name}'s Organization` });
|
||||
ctx.auditLoggingCtx.organizationId = organization.id;
|
||||
|
||||
await createMembership(organization.id, user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
...user.notificationSettings,
|
||||
alert: { ...user.notificationSettings?.alert },
|
||||
weeklySummary: { ...user.notificationSettings?.weeklySummary },
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds ?? []), organization.id])
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePostUserCreation(
|
||||
ctx: ActionClientCtx,
|
||||
user: TCreatedUser,
|
||||
inviteToken: string | undefined,
|
||||
emailVerificationDisabled: boolean | undefined
|
||||
): Promise<void> {
|
||||
if (inviteToken) {
|
||||
await handleInviteAcceptance(ctx, inviteToken, user);
|
||||
} else {
|
||||
await handleOrganizationCreation(ctx, user);
|
||||
}
|
||||
|
||||
if (!emailVerificationDisabled) {
|
||||
await sendVerificationEmail(user);
|
||||
}
|
||||
}
|
||||
|
||||
export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
if (IS_TURNSTILE_CONFIGURED) {
|
||||
if (!parsedInput.turnstileToken || !TURNSTILE_SECRET_KEY) {
|
||||
captureFailedSignup(parsedInput.email, parsedInput.name);
|
||||
throw new UnknownError("Server configuration error");
|
||||
}
|
||||
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
|
||||
|
||||
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, parsedInput.turnstileToken);
|
||||
if (!isHuman) {
|
||||
captureFailedSignup(parsedInput.email, parsedInput.name);
|
||||
throw new UnknownError("reCAPTCHA verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
const { inviteToken, emailVerificationDisabled } = parsedInput;
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const user = await createUser({
|
||||
email: parsedInput.email.toLowerCase(),
|
||||
name: parsedInput.name,
|
||||
password: hashedPassword,
|
||||
locale: parsedInput.userLocale,
|
||||
});
|
||||
const { user, userAlreadyExisted } = await createUserSafely(
|
||||
parsedInput.email,
|
||||
parsedInput.name,
|
||||
hashedPassword,
|
||||
parsedInput.userLocale
|
||||
);
|
||||
|
||||
// Handle invite flow
|
||||
if (inviteToken) {
|
||||
const inviteTokenData = verifyInviteToken(inviteToken);
|
||||
const invite = await getInvite(inviteTokenData.inviteId);
|
||||
if (!invite) {
|
||||
throw new Error("Invalid invite ID");
|
||||
}
|
||||
|
||||
await createMembership(invite.organizationId, user.id, {
|
||||
accepted: true,
|
||||
role: invite.role,
|
||||
});
|
||||
|
||||
if (invite.teamIds) {
|
||||
await createTeamMembership(
|
||||
{
|
||||
organizationId: invite.organizationId,
|
||||
role: invite.role,
|
||||
teamIds: invite.teamIds,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [invite.organizationId],
|
||||
},
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = invite.organizationId;
|
||||
|
||||
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
|
||||
await deleteInvite(invite.id);
|
||||
} else {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (isMultiOrgEnabled) {
|
||||
const organization = await createOrganization({ name: `${user.name}'s Organization` });
|
||||
await createMembership(organization.id, user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
...user.notificationSettings,
|
||||
alert: { ...user.notificationSettings?.alert },
|
||||
weeklySummary: { ...user.notificationSettings?.weeklySummary },
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organization.id;
|
||||
}
|
||||
if (!userAlreadyExisted && user) {
|
||||
await handlePostUserCreation(
|
||||
ctx,
|
||||
user,
|
||||
parsedInput.inviteToken,
|
||||
parsedInput.emailVerificationDisabled
|
||||
);
|
||||
}
|
||||
|
||||
// Send verification email if enabled
|
||||
if (!emailVerificationDisabled) {
|
||||
await sendVerificationEmail(user);
|
||||
if (user) {
|
||||
ctx.auditLoggingCtx.userId = user.id;
|
||||
ctx.auditLoggingCtx.newObject = user;
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = user.id;
|
||||
ctx.auditLoggingCtx.newObject = user;
|
||||
return user;
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -125,6 +125,7 @@ const defaultProps = {
|
||||
describe("SignupForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("toggles the signup form on button click", () => {
|
||||
@@ -237,7 +238,7 @@ describe("SignupForm", () => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success");
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success?token=token123");
|
||||
});
|
||||
|
||||
test("submits the form successfully when turnstile is configured, but createEmailTokenAction don't return data", async () => {
|
||||
@@ -364,4 +365,42 @@ describe("SignupForm", () => {
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123");
|
||||
});
|
||||
|
||||
test("shows an error message when createUserAction fails", async () => {
|
||||
// Set up mocks for the API actions
|
||||
vi.mocked(createUserAction).mockResolvedValue(undefined);
|
||||
vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" });
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValue("user creation failed");
|
||||
|
||||
render(<SignupForm {...defaultProps} />);
|
||||
|
||||
// Click the button to reveal the signup form
|
||||
const toggleButton = screen.getByTestId("signup-show-login");
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Fill out the form fields
|
||||
fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } });
|
||||
fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } });
|
||||
fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } });
|
||||
|
||||
// Submit the form.
|
||||
const submitButton = screen.getByTestId("signup-submit");
|
||||
fireEvent.submit(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createUserAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
});
|
||||
|
||||
// An error message should be shown.
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("user creation failed");
|
||||
});
|
||||
|
||||
// router.push should not have been called.
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,7 @@ import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -109,21 +108,22 @@ export const SignupForm = ({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
userLocale,
|
||||
inviteToken: inviteToken || "",
|
||||
inviteToken: inviteToken ?? "",
|
||||
emailVerificationDisabled,
|
||||
turnstileToken,
|
||||
});
|
||||
|
||||
if (createUserResponse?.data) {
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
if (emailTokenActionResponse?.data) {
|
||||
const token = emailTokenActionResponse?.data;
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success`
|
||||
: `/auth/verification-requested?token=${token}`;
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
const token = emailTokenActionResponse?.data;
|
||||
|
||||
router.push(url);
|
||||
} else {
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success?token=${token}`
|
||||
: `/auth/verification-requested?token=${token}`;
|
||||
|
||||
if (createUserResponse?.data) {
|
||||
router.push(url);
|
||||
|
||||
if (!emailTokenActionResponse?.data) {
|
||||
if (isTurnstileConfigured) {
|
||||
setTurnstileToken(undefined);
|
||||
turnstile.reset();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendVerificationEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
const ZResendVerificationEmailAction = z.object({
|
||||
@@ -23,10 +23,15 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica
|
||||
throw new ResourceNotFoundError("user", parsedInput.email);
|
||||
}
|
||||
if (user.emailVerified) {
|
||||
throw new InvalidInputError("Email address has already been verified");
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
ctx.auditLoggingCtx.userId = user.id;
|
||||
return await sendVerificationEmail(user);
|
||||
await sendVerificationEmail(user);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
138
apps/web/modules/auth/verification-requested/page.test.tsx
Normal file
138
apps/web/modules/auth/verification-requested/page.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { getEmailFromEmailToken } from "@/lib/jwt";
|
||||
import { VerificationRequestedPage } from "@/modules/auth/verification-requested/page";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
getEmailFromEmailToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
T: ({ keyName, params }) => {
|
||||
if (params && params.email) {
|
||||
return `${keyName} ${params.email}`;
|
||||
}
|
||||
return keyName;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
INTERCOM_SECRET_KEY: "test-secret-key",
|
||||
IS_INTERCOM_CONFIGURED: true,
|
||||
INTERCOM_APP_ID: "test-app-id",
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
POSTHOG_API_HOST: "test-posthog-api-host",
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AVAILABLE_LOCALES: ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"],
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/form-wrapper", () => ({
|
||||
FormWrapper: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/verification-requested/components/request-verification-email", () => ({
|
||||
RequestVerificationEmail: ({ email }) => <div>Mocked RequestVerificationEmail: {email}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe("VerificationRequestedPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the page with valid email", async () => {
|
||||
const mockEmail = "test@example.com";
|
||||
vi.mocked(getEmailFromEmailToken).mockReturnValue(mockEmail);
|
||||
|
||||
const searchParams = { token: "valid-token" };
|
||||
const Page = await VerificationRequestedPage({ searchParams });
|
||||
render(Page);
|
||||
|
||||
expect(
|
||||
screen.getByText("auth.verification-requested.please_confirm_your_email_address")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/test@example\.com/)).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"auth.verification-requested.verification_email_successfully_sent_info test@example.com"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`Mocked RequestVerificationEmail: ${mockEmail.toLowerCase()}`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders invalid email message when email parsing fails", async () => {
|
||||
vi.mocked(getEmailFromEmailToken).mockReturnValue("invalid-email");
|
||||
|
||||
const searchParams = { token: "valid-token" };
|
||||
const Page = await VerificationRequestedPage({ searchParams });
|
||||
render(Page);
|
||||
|
||||
expect(screen.getByText("auth.verification-requested.invalid_email_address")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders invalid token message when token is invalid", async () => {
|
||||
const mockError = new Error("Invalid token");
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
vi.mocked(getEmailFromEmailToken).mockImplementation(() => {
|
||||
throw mockError;
|
||||
});
|
||||
|
||||
const searchParams = { token: "invalid-token" };
|
||||
const Page = await VerificationRequestedPage({ searchParams });
|
||||
render(Page);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(mockError, "Invalid token");
|
||||
expect(screen.getByText("auth.verification-requested.invalid_token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls logger.error when token parsing throws an error", async () => {
|
||||
const mockError = new Error("JWT malformed");
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
vi.mocked(getEmailFromEmailToken).mockImplementation(() => {
|
||||
throw mockError;
|
||||
});
|
||||
|
||||
const searchParams = { token: "malformed-token" };
|
||||
await VerificationRequestedPage({ searchParams });
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(mockError, "Invalid token");
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { getEmailFromEmailToken } from "@/lib/jwt";
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { RequestVerificationEmail } from "@/modules/auth/verification-requested/components/request-verification-email";
|
||||
import { T, getTranslate } from "@/tolgee/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
export const VerificationRequestedPage = async ({ searchParams }) => {
|
||||
@@ -19,10 +20,9 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
|
||||
</h1>
|
||||
<p className="text-center text-sm text-slate-700">
|
||||
<T
|
||||
keyName="auth.verification-requested.we_sent_an_email_to"
|
||||
keyName="auth.verification-requested.verification_email_successfully_sent_info"
|
||||
params={{ email, span: <span /> }}
|
||||
/>
|
||||
{t("auth.verification-requested.please_click_the_link_in_the_email_to_activate_your_account")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
@@ -42,6 +42,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Invalid token");
|
||||
return (
|
||||
<FormWrapper>
|
||||
<p className="text-center">{t("auth.verification-requested.invalid_token")}</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ENCRYPTION_KEY, SURVEY_URL } from "@/lib/constants";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import * as contactSurveyLink from "./contact-survey-link";
|
||||
@@ -15,7 +16,10 @@ vi.mock("jsonwebtoken", () => ({
|
||||
// Mock constants - MUST be a literal object without using variables
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
|
||||
SURVEY_URL: "https://test.formbricks.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn().mockReturnValue("https://test.formbricks.com"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
@@ -73,7 +77,7 @@ describe("Contact Survey Link", () => {
|
||||
// Verify the returned URL
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${SURVEY_URL}/c/${mockToken}`,
|
||||
data: `${getPublicDomain()}/c/${mockToken}`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +102,7 @@ describe("Contact Survey Link", () => {
|
||||
// Re‑mock constants to simulate missing ENCRYPTION_KEY
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
SURVEY_URL: "https://test.formbricks.com",
|
||||
PUBLIC_URL: "https://test.formbricks.com",
|
||||
}));
|
||||
// Re‑import the modules so they pick up the new mock
|
||||
const { getContactSurveyLink } = await import("./contact-survey-link");
|
||||
@@ -172,7 +176,6 @@ describe("Contact Survey Link", () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
SURVEY_URL: "https://test.formbricks.com",
|
||||
}));
|
||||
const { verifyContactSurveyToken } = await import("./contact-survey-link");
|
||||
const result = verifyContactSurveyToken(mockToken);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
@@ -42,7 +42,7 @@ export const getContactSurveyLink = (
|
||||
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
|
||||
|
||||
// Return the personalized URL
|
||||
return ok(`${getSurveyDomain()}/c/${token}`);
|
||||
return ok(`${getPublicDomain()}/c/${token}`);
|
||||
};
|
||||
|
||||
// Validates and decrypts a contact survey JWT token
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { isLight, mixColor } from "@/lib/utils/colors";
|
||||
@@ -46,6 +47,31 @@ export const getPreviewEmailTemplateHtml = async (
|
||||
);
|
||||
};
|
||||
|
||||
const getRatingContent = (scale: string, i: number, range: number, isColorCodingEnabled: boolean) => {
|
||||
if (scale === "smiley") {
|
||||
return (
|
||||
<RatingSmiley
|
||||
active={false}
|
||||
idx={i}
|
||||
range={range}
|
||||
addColors={isColorCodingEnabled}
|
||||
baseUrl={WEBAPP_URL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (scale === "number") {
|
||||
return (
|
||||
<Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">
|
||||
{i + 1}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (scale === "star") {
|
||||
return <Text className="m-auto text-3xl">⭐</Text>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function PreviewEmailTemplate({
|
||||
survey,
|
||||
surveyUrl,
|
||||
@@ -124,16 +150,13 @@ export async function PreviewEmailTemplate({
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i.toString()}`}
|
||||
key={i}
|
||||
className={cn(
|
||||
firstQuestion.isColorCodingEnabled ? "h-[46px]" : "h-10",
|
||||
firstQuestion.isColorCodingEnabled && firstQuestion.scale === "number"
|
||||
? `h-[46px] border border-t-[6px] border-t-${getNPSOptionColor(i + 1).replace("bg-", "")}`
|
||||
: "h-10",
|
||||
"relative m-0 w-full overflow-hidden border border-l-0 border-solid border-slate-200 p-0 text-center align-middle leading-10 text-slate-800",
|
||||
{ "rounded-l-lg border-l": i === 0 },
|
||||
{ "rounded-r-lg": i === 10 }
|
||||
)}>
|
||||
{firstQuestion.isColorCodingEnabled ? (
|
||||
<Section
|
||||
className={`absolute left-0 top-0 h-[6px] w-full ${getNPSOptionColor(i)}`}
|
||||
/>
|
||||
) : null}
|
||||
{i}
|
||||
</EmailButton>
|
||||
))}
|
||||
@@ -204,36 +227,23 @@ export async function PreviewEmailTemplate({
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
className={cn(
|
||||
"relative m-0 flex w-full items-center justify-center overflow-hidden border border-l-0 border-solid border-gray-200 p-0 text-center align-middle leading-10 text-slate-800",
|
||||
"relative m-0 h-[48px] w-full overflow-hidden border border-l-0 border-solid border-gray-200 p-0 text-center align-middle leading-10 text-slate-800",
|
||||
|
||||
{ "rounded-l-lg border-l": i === 0 },
|
||||
{ "rounded-r-lg": i === firstQuestion.range - 1 },
|
||||
firstQuestion.isColorCodingEnabled && firstQuestion.scale === "number"
|
||||
? "h-[46px]"
|
||||
: "h-10",
|
||||
firstQuestion.scale === "star" ? "h-12" : "h-10"
|
||||
firstQuestion.isColorCodingEnabled &&
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "star" && "border-transparent"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}
|
||||
key={i}>
|
||||
{firstQuestion.scale === "smiley" && (
|
||||
<RatingSmiley
|
||||
active={false}
|
||||
idx={i}
|
||||
range={firstQuestion.range}
|
||||
addColors={firstQuestion.isColorCodingEnabled}
|
||||
/>
|
||||
{getRatingContent(
|
||||
firstQuestion.scale,
|
||||
i,
|
||||
firstQuestion.range,
|
||||
firstQuestion.isColorCodingEnabled
|
||||
)}
|
||||
{firstQuestion.scale === "number" && (
|
||||
<>
|
||||
{firstQuestion.isColorCodingEnabled ? (
|
||||
<Section
|
||||
className={`absolute left-0 top-0 h-[6px] w-full ${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`}
|
||||
/>
|
||||
) : null}
|
||||
<Text className="m-0 flex h-10 items-center">{i + 1}</Text>
|
||||
</>
|
||||
)}
|
||||
{firstQuestion.scale === "star" && <Text className="m-0 text-3xl">⭐</Text>}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Column>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user