mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87cc477547 |
@@ -106,13 +106,6 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion reauthentication #
|
||||
###########################################
|
||||
|
||||
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
|
||||
|
||||
|
||||
##########
|
||||
# Other #
|
||||
@@ -139,9 +132,6 @@ GITHUB_SECRET=
|
||||
# Configure Google Login
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
|
||||
# Keep this unset until that setting is active for the OAuth app.
|
||||
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
|
||||
|
||||
# Configure Azure Active Directory Login
|
||||
AZUREAD_CLIENT_ID=
|
||||
|
||||
@@ -155,31 +155,3 @@ jobs:
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Complete Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Linear Release Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linear-release:
|
||||
name: Sync release to Linear
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
+12
-12
@@ -11,19 +11,19 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "5.0.2",
|
||||
"@storybook/addon-a11y": "10.3.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-links": "10.3.6",
|
||||
"@storybook/addon-onboarding": "10.3.6",
|
||||
"@storybook/react-vite": "10.3.6",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.3.5",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-links": "10.3.5",
|
||||
"@storybook/addon-onboarding": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.3.6",
|
||||
"storybook": "10.3.6",
|
||||
"vite": "7.3.3"
|
||||
"eslint-plugin-storybook": "10.3.5",
|
||||
"storybook": "10.3.5",
|
||||
"vite": "7.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
-11
@@ -2,7 +2,6 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
|
||||
|
||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "surveys",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
@@ -243,7 +242,7 @@ export const ProjectSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
|
||||
-13
@@ -7,7 +7,6 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -52,18 +51,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
if (searchParams.mode === "cx") {
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "cx",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -81,19 +80,6 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
groupIdentifyPostHog("workspace", project.id, { name: project.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"workspace_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: project.id,
|
||||
name: project.name,
|
||||
},
|
||||
{ organizationId, workspaceId: project.id }
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = project.id;
|
||||
ctx.auditLoggingCtx.newObject = project;
|
||||
|
||||
@@ -2,8 +2,6 @@ import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
@@ -27,14 +25,6 @@ const EnvLayout = async (props: {
|
||||
return (
|
||||
<>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
{POSTHOG_KEY && (
|
||||
<PostHogGroupIdentify
|
||||
organizationId={layoutData.organization.id}
|
||||
organizationName={layoutData.organization.name}
|
||||
workspaceId={layoutData.project.id}
|
||||
workspaceName={layoutData.project.name}
|
||||
/>
|
||||
)}
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
|
||||
+4
-2
@@ -6,9 +6,11 @@ import {
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
|
||||
+8
-47
@@ -1,68 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import {
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
} from "@/modules/account/constants";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface DeleteAccountProps {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
accountDeletionError?: string | string[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccount = ({
|
||||
session,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
user,
|
||||
organizationsWithSingleOwner,
|
||||
accountDeletionError,
|
||||
isMultiOrgEnabled,
|
||||
requiresPasswordConfirmation,
|
||||
}: Readonly<DeleteAccountProps>) => {
|
||||
}: {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
}) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||
const { t } = useTranslation();
|
||||
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
|
||||
? accountDeletionError[0]
|
||||
: accountDeletionError;
|
||||
const hasShownAccountDeletionError = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownAccountDeletionError.current = true;
|
||||
|
||||
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
||||
globalThis.history.replaceState(null, "", url.toString());
|
||||
}, [accountDeletionErrorCode, t]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
@@ -70,7 +32,6 @@ export const DeleteAccount = ({
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
user={user}
|
||||
|
||||
+87
-1
@@ -1,6 +1,12 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getIsEmailUnique } from "./user";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -11,12 +17,92 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
|
||||
+37
@@ -1,5 +1,42 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
+3
-16
@@ -1,16 +1,10 @@
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
PASSWORD_RESET_DISABLED,
|
||||
} from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -21,14 +15,10 @@ import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
|
||||
}) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslate();
|
||||
const { environmentId } = params;
|
||||
|
||||
@@ -43,7 +33,6 @@ const Page = async (props: {
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -76,7 +65,7 @@ const Page = async (props: {
|
||||
: t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
@@ -101,8 +90,6 @@ const Page = async (props: {
|
||||
user={user}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
+7
-12
@@ -5,7 +5,7 @@ import { RotateCcwIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
@@ -151,17 +151,12 @@ export const EnterpriseLicenseStatus = ({
|
||||
</Alert>
|
||||
)}
|
||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||
<Trans
|
||||
i18nKey="environments.settings.enterprise.questions_please_reach_out_to_email"
|
||||
components={{
|
||||
contactLink: (
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com">
|
||||
hola@formbricks.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -173,7 +173,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
referrerPolicy="no-referrer">
|
||||
|
||||
+1
-7
@@ -1,11 +1,6 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
FB_LOGO_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -85,7 +80,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
user={user}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
<SettingsCard
|
||||
|
||||
+15
-41
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
ElementOption,
|
||||
ElementOptions,
|
||||
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
|
||||
|
||||
export interface DateRange {
|
||||
from: Date | undefined;
|
||||
to?: Date;
|
||||
to?: Date | undefined;
|
||||
}
|
||||
|
||||
interface FilterDateContextProps {
|
||||
@@ -41,8 +41,6 @@ interface FilterDateContextProps {
|
||||
dateRange: DateRange;
|
||||
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
|
||||
resetState: () => void;
|
||||
refreshAnalysisData: () => Promise<void>;
|
||||
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
|
||||
}
|
||||
|
||||
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
|
||||
@@ -63,7 +61,6 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
from: undefined,
|
||||
to: getTodayDate(),
|
||||
});
|
||||
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setDateRange({
|
||||
@@ -76,43 +73,20 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshAnalysisData = useCallback(async () => {
|
||||
await refreshHandlerRef.current?.();
|
||||
}, []);
|
||||
|
||||
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
|
||||
refreshHandlerRef.current = handler;
|
||||
|
||||
return () => {
|
||||
if (refreshHandlerRef.current === handler) {
|
||||
refreshHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
}),
|
||||
[
|
||||
dateRange,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
resetState,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
]
|
||||
return (
|
||||
<ResponseFilterContext.Provider
|
||||
value={{
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
}}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
);
|
||||
|
||||
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
|
||||
};
|
||||
|
||||
const useResponseFilter = () => {
|
||||
|
||||
+2
-35
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
@@ -15,7 +13,6 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
|
||||
interface ResponsePageProps {
|
||||
@@ -49,8 +46,8 @@ export const ResponsePage = ({
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
|
||||
@@ -89,34 +86,6 @@ export const ResponsePage = ({
|
||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||
};
|
||||
|
||||
const refetchResponses = useCallback(async () => {
|
||||
setIsFetchingFirstPage(true);
|
||||
|
||||
try {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
if (getResponsesActionResponse?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
|
||||
}
|
||||
|
||||
const freshResponses = getResponsesActionResponse?.data ?? [];
|
||||
setResponses(freshResponses);
|
||||
setPage(1);
|
||||
setHasMore(freshResponses.length >= responsesPerPage);
|
||||
} finally {
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
}, [filters, responsesPerPage, surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refetchResponses);
|
||||
}, [refetchResponses, registerAnalysisRefreshHandler]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
}, [survey]);
|
||||
@@ -165,8 +134,6 @@ export const ResponsePage = ({
|
||||
}
|
||||
};
|
||||
fetchFilteredResponses();
|
||||
// page is intentionally omitted to avoid refetching after the initial page setup.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
|
||||
return (
|
||||
|
||||
+1
-7
@@ -2,12 +2,7 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
|
||||
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 {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
RESPONSES_PER_PAGE,
|
||||
} from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -77,7 +72,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="responses" />
|
||||
|
||||
+2
-16
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -147,7 +146,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.inputSchema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||
@@ -155,7 +153,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -163,7 +161,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId,
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -180,18 +178,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
throw new UnknownError("No contacts found for the selected segment");
|
||||
}
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"personal_link_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
survey_id: parsedInput.surveyId,
|
||||
link_count: contactsResult.length,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
// Prepare CSV data with the specified headers and order
|
||||
const csvHeaders = [
|
||||
"Formbricks Contact ID",
|
||||
|
||||
+19
-35
@@ -71,7 +71,7 @@ export const SummaryPage = ({
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||
@@ -111,7 +111,7 @@ export const SummaryPage = ({
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays]);
|
||||
}, [fetchDisplays, t]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
@@ -131,39 +131,13 @@ export const SummaryPage = ({
|
||||
}
|
||||
}, [tab, loadInitialDisplays]);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
const updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
if (updatedSurveySummary?.serverError) {
|
||||
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
|
||||
}
|
||||
|
||||
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
|
||||
}, [dateRange, selectedFilter, survey, surveyId]);
|
||||
|
||||
const refreshSummary = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchSummary, loadInitialDisplays, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refreshSummary);
|
||||
}, [refreshSummary, registerAnalysisRefreshHandler]);
|
||||
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
const hasNoFilters =
|
||||
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
|
||||
(!selectedFilter ||
|
||||
Object.keys(selectedFilter).length === 0 ||
|
||||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
|
||||
(!dateRange || (!dateRange.from && !dateRange.to));
|
||||
|
||||
if (initialSurveySummary && hasNoFilters) {
|
||||
@@ -171,11 +145,21 @@ export const SummaryPage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchFilteredSummary = async () => {
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await fetchSummary();
|
||||
// Recalculate filters inside the effect to ensure we have the latest values
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
@@ -183,8 +167,8 @@ export const SummaryPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
fetchFilteredSummary();
|
||||
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
|
||||
+2
-27
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
@@ -31,7 +30,6 @@ interface SurveyAnalysisCTAProps {
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -48,7 +46,6 @@ export const SurveyAnalysisCTA = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
isStorageConfigured,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -61,12 +58,10 @@ export const SurveyAnalysisCTA = ({
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { environment, project } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
const { refreshAnalysisData } = useResponseFilter();
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -78,7 +73,7 @@ export const SurveyAnalysisCTA = ({
|
||||
}, [searchParams]);
|
||||
|
||||
const handleShareModalToggle = (open: boolean) => {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const currentShareParam = params.get("share") === "true";
|
||||
|
||||
if (open && !currentShareParam) {
|
||||
@@ -148,25 +143,6 @@ export const SurveyAnalysisCTA = ({
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: RefreshCcwIcon,
|
||||
tooltip: t("common.refresh"),
|
||||
onClick: async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshAnalysisData();
|
||||
toast.success(t("common.data_refreshed_successfully"));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
disabled: isRefreshing,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: BellRing,
|
||||
tooltip: t("environments.surveys.summary.configure_alerts"),
|
||||
@@ -233,7 +209,6 @@ export const SurveyAnalysisCTA = ({
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage />
|
||||
|
||||
-3
@@ -54,7 +54,6 @@ interface ShareSurveyModalProps {
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
projectCustomScripts?: string | null;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -70,7 +69,6 @@ export const ShareSurveyModal = ({
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
@@ -110,7 +108,6 @@ export const ShareSurveyModal = ({
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
|
||||
+5
-69
@@ -2,7 +2,7 @@
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import { CopyIcon, SendIcon } from "lucide-react";
|
||||
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
@@ -21,7 +21,6 @@ interface EmailTabProps {
|
||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const emailHtml = useMemo(() => {
|
||||
@@ -32,40 +31,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const sanitizedEmailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const emailPreviewDocument = useMemo(() => {
|
||||
if (!sanitizedEmailHtml) return "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="only light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
supported-color-schemes: light;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color-scheme: only light;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${sanitizedEmailHtml}</body>
|
||||
</html>`;
|
||||
}, [sanitizedEmailHtml]);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "preview",
|
||||
@@ -86,25 +51,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
getData();
|
||||
}, [surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewFrameHeight(560);
|
||||
}, [emailPreviewDocument]);
|
||||
|
||||
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
|
||||
const { contentDocument } = event.currentTarget;
|
||||
if (!contentDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHeight = Math.max(
|
||||
contentDocument.body.scrollHeight,
|
||||
contentDocument.documentElement.scrollHeight,
|
||||
560
|
||||
);
|
||||
|
||||
setPreviewFrameHeight(nextHeight);
|
||||
};
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
try {
|
||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||
@@ -127,9 +73,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
if (activeTab === "preview") {
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
|
||||
data-testid="survey-email-preview-shell">
|
||||
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
@@ -143,17 +87,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
|
||||
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
|
||||
</div>
|
||||
<div data-testid="survey-email-preview-content">
|
||||
{emailPreviewDocument ? (
|
||||
<iframe
|
||||
className="mt-2 w-full rounded-md border-0 bg-white"
|
||||
data-testid="survey-email-preview-frame"
|
||||
onLoad={handlePreviewFrameLoad}
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
|
||||
srcDoc={emailPreviewDocument}
|
||||
style={{ height: `${previewFrameHeight}px` }}
|
||||
title={t("environments.surveys.share.send_email.email_preview_tab")}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
+1
-3
@@ -34,7 +34,6 @@ interface PersonalLinksTabProps {
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface PersonalLinksFormData {
|
||||
@@ -75,7 +74,6 @@ export const PersonalLinksTab = ({
|
||||
surveyId,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: PersonalLinksTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -171,7 +169,7 @@ export const PersonalLinksTab = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
describe("extractEmailBodyFragment", () => {
|
||||
test("returns the body contents for rendered email documents", () => {
|
||||
const html = `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body class="email-body">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Preview content</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
expect(extractEmailBodyFragment(html)).toBe(
|
||||
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
|
||||
);
|
||||
});
|
||||
|
||||
test("removes document-level tags from rendered survey email markup", () => {
|
||||
const fragment = extractEmailBodyFragment(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Which fruits do you like</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
expect(fragment).toBe(
|
||||
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
|
||||
);
|
||||
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
||||
});
|
||||
|
||||
test("falls back to the original markup when no body tag exists", () => {
|
||||
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
|
||||
});
|
||||
|
||||
test("removes React server markers from rendered fragments", () => {
|
||||
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
|
||||
"<div>Preview content</div>"
|
||||
);
|
||||
});
|
||||
});
|
||||
+5
-4
@@ -1,12 +1,10 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
|
||||
const t = await getTranslate();
|
||||
@@ -19,9 +17,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
|
||||
const styling = getStyling(project, survey);
|
||||
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">';
|
||||
const htmlCleaned = html.toString().replace(doctype, "");
|
||||
|
||||
return extractEmailBodyFragment(html.toString());
|
||||
return htmlCleaned;
|
||||
};
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
|
||||
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
|
||||
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
|
||||
|
||||
export const extractEmailBodyFragment = (html: string): string => {
|
||||
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
|
||||
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
|
||||
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
|
||||
|
||||
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
|
||||
};
|
||||
-15
@@ -1106,21 +1106,6 @@ describe("getSurveySummary", () => {
|
||||
expect.objectContaining({ responseIds: expect.any(Array) })
|
||||
);
|
||||
});
|
||||
|
||||
test("does not pass responseIds for date-only filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = {
|
||||
createdAt: {
|
||||
min: new Date("2024-01-01T00:00:00.000Z"),
|
||||
max: new Date("2024-01-31T23:59:59.999Z"),
|
||||
},
|
||||
};
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
|
||||
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, {
|
||||
createdAt: filterCriteria.createdAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesForSummary", () => {
|
||||
|
||||
+1
-1
@@ -979,7 +979,7 @@ export const getSurveySummary = reactCache(
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const batchSize = 5000;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).some((filterKey) => filterKey !== "createdAt");
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TSurveySummaryResponse[] = [];
|
||||
|
||||
+1
-7
@@ -4,12 +4,7 @@ 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,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -79,7 +74,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="summary" />
|
||||
|
||||
@@ -42,25 +42,18 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
const result = await getResponseDownloadFile(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.format,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"responses_exported",
|
||||
{
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "responses_exported", {
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -43,22 +43,14 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
verifyAccountDeletionSsoReauthIntent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/account/lib/account-deletion", () => ({
|
||||
deleteUserWithAccountDeletionAuthorization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetServerSession = vi.mocked(getServerSession);
|
||||
const mockLoggerError = vi.mocked(logger.error);
|
||||
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
|
||||
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
|
||||
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
|
||||
|
||||
const intent = {
|
||||
id: "intent-id",
|
||||
email: "delete-user@example.com",
|
||||
provider: "google",
|
||||
providerAccountId: "google-account-id",
|
||||
purpose: "account_deletion_sso_reauth" as const,
|
||||
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
|
||||
userId: "user-id",
|
||||
};
|
||||
|
||||
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: intent.email,
|
||||
id: intent.userId,
|
||||
},
|
||||
} as any);
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
|
||||
oldUser: { id: intent.userId } as any,
|
||||
});
|
||||
mockQueueAuditEventBackground.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("returns login without deleting when the callback has no intent", async () => {
|
||||
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
|
||||
"/auth/login"
|
||||
);
|
||||
|
||||
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deletes the account after a completed SSO reauthentication", async () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
confirmationEmail: intent.email,
|
||||
userEmail: intent.email,
|
||||
userId: intent.userId,
|
||||
});
|
||||
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: intent.userId,
|
||||
userType: "user",
|
||||
targetId: intent.userId,
|
||||
organizationId: "unknown",
|
||||
oldObject: { id: intent.userId },
|
||||
status: "success",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not delete when the callback session does not match the intent user", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: "other@example.com",
|
||||
id: "other-user-id",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(AuthorizationError) },
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
|
||||
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to login when the intent return URL is not allowed", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
|
||||
...intent,
|
||||
returnToUrl: "https://evil.example/settings/profile",
|
||||
});
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: "other@example.com",
|
||||
id: "other-user-id",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import "server-only";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
type TAccountDeletionSsoCompleteSearchParams = {
|
||||
intent?: string | string[];
|
||||
};
|
||||
|
||||
const getIntentToken = (intent: string | string[] | undefined) => {
|
||||
if (Array.isArray(intent)) {
|
||||
return intent[0];
|
||||
}
|
||||
|
||||
return intent;
|
||||
};
|
||||
|
||||
const getSafeRedirectPath = (returnToUrl: string) => {
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
return "/auth/login";
|
||||
}
|
||||
|
||||
const parsedReturnToUrl = new URL(validatedReturnToUrl);
|
||||
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
|
||||
};
|
||||
|
||||
const getPostDeletionRedirectPath = () =>
|
||||
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
|
||||
|
||||
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
|
||||
intent,
|
||||
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
|
||||
const intentToken = getIntentToken(intent);
|
||||
let redirectPath = "/auth/login";
|
||||
|
||||
if (!intentToken) {
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
||||
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
|
||||
}
|
||||
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail: verifiedIntent.email,
|
||||
userEmail: session.user.email,
|
||||
userId: session.user.id,
|
||||
});
|
||||
redirectPath = getPostDeletionRedirectPath();
|
||||
await queueAuditEventBackground({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: session.user.id,
|
||||
userType: "user",
|
||||
targetId: session.user.id,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
oldObject: oldUser,
|
||||
status: "success",
|
||||
});
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
|
||||
}
|
||||
|
||||
return redirectPath;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
export default async function AccountDeletionSsoReauthCompletePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ intent?: string | string[] }>;
|
||||
}) {
|
||||
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
|
||||
}
|
||||
@@ -12,7 +12,6 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
|
||||
const makeParams = (responseCount: number) => ({
|
||||
organizationId: "org-1",
|
||||
workspaceId: "ws-1",
|
||||
surveyId: "survey-1",
|
||||
surveyType: "link",
|
||||
environmentId: "env-1",
|
||||
@@ -24,21 +23,15 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
|
||||
captureSurveyResponsePostHogEvent(makeParams(1));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"survey_response_received",
|
||||
{
|
||||
survey_id: "survey-1",
|
||||
survey_type: "link",
|
||||
organization_id: "org-1",
|
||||
workspace_id: "ws-1",
|
||||
environment_id: "env-1",
|
||||
response_count: 1,
|
||||
is_first_response: true,
|
||||
milestone: "first",
|
||||
},
|
||||
{ organizationId: "org-1", workspaceId: "ws-1" }
|
||||
);
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
|
||||
survey_id: "survey-1",
|
||||
survey_type: "link",
|
||||
organization_id: "org-1",
|
||||
environment_id: "env-1",
|
||||
response_count: 1,
|
||||
is_first_response: true,
|
||||
milestone: "first",
|
||||
});
|
||||
});
|
||||
|
||||
test("fires on every 100th response", async () => {
|
||||
@@ -51,20 +44,10 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
test("fires on every 10th response up to 100", async () => {
|
||||
test("does NOT fire for 2nd through 99th responses", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
test("does NOT fire for non-milestone responses under 100", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [2, 5, 11, 25, 49, 51, 99]) {
|
||||
for (const count of [2, 5, 10, 50, 99]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
@@ -92,8 +75,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
expect.objectContaining({
|
||||
is_first_response: false,
|
||||
milestone: "200",
|
||||
}),
|
||||
{ organizationId: "org-1", workspaceId: "ws-1" }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
|
||||
|
||||
interface SurveyResponsePostHogEventParams {
|
||||
organizationId: string;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
surveyType: string;
|
||||
environmentId: string;
|
||||
@@ -11,36 +10,24 @@ interface SurveyResponsePostHogEventParams {
|
||||
|
||||
/**
|
||||
* Captures a PostHog event for survey responses at milestones:
|
||||
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
|
||||
* then every 100th (200, 300, 400, ...).
|
||||
* 1st response, then every 100th (100, 200, 300, ...).
|
||||
*/
|
||||
export const captureSurveyResponsePostHogEvent = ({
|
||||
organizationId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
surveyType,
|
||||
environmentId,
|
||||
responseCount,
|
||||
}: SurveyResponsePostHogEventParams): void => {
|
||||
const isFirst = responseCount === 1;
|
||||
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
|
||||
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
|
||||
if (responseCount !== 1 && responseCount % 100 !== 0) return;
|
||||
|
||||
if (!isFirst && !isEvery10thUnder100 && !isEvery100thAbove100) return;
|
||||
|
||||
capturePostHogEvent(
|
||||
organizationId,
|
||||
"survey_response_received",
|
||||
{
|
||||
survey_id: surveyId,
|
||||
survey_type: surveyType,
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
environment_id: environmentId,
|
||||
response_count: responseCount,
|
||||
is_first_response: responseCount === 1,
|
||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
capturePostHogEvent(organizationId, "survey_response_received", {
|
||||
survey_id: surveyId,
|
||||
survey_type: surveyType,
|
||||
organization_id: organizationId,
|
||||
environment_id: environmentId,
|
||||
response_count: responseCount,
|
||||
is_first_response: responseCount === 1,
|
||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import type { Agent } from "undici";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -9,15 +8,14 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
|
||||
@@ -93,25 +91,12 @@ export const POST = async (request: Request) => {
|
||||
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
||||
// Prepare webhook and email promises
|
||||
|
||||
// Fetch with timeout of 5 seconds to prevent hanging.
|
||||
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
|
||||
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
|
||||
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
|
||||
// pre-patch redirect-follow behavior for consistency.
|
||||
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||
// Uses AbortSignal to actually cancel the underlying fetch when the timer fires —
|
||||
// a Promise.race would only reject the wrapper while the fetch keeps the socket
|
||||
// open, which then deadlocks dispatcher.close() (graceful drain waits for it).
|
||||
const fetchWithTimeout = (
|
||||
url: string,
|
||||
options: RequestInit & { dispatcher?: Agent },
|
||||
timeout: number = 5000
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
redirect: redirectMode,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
} as RequestInit & { dispatcher?: Agent });
|
||||
// Fetch with timeout of 5 seconds to prevent hanging
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, { ...options, redirect: "manual" }),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
|
||||
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||
@@ -153,24 +138,14 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
return validateAndResolveWebhookUrl(webhook.url)
|
||||
.then(async (address) => {
|
||||
// Pin TCP connect to the validated IP. Without this, undici resolves DNS
|
||||
// again at fetch time and an attacker-controlled domain can rebind to a
|
||||
// private/internal IP after validation passed (TOCTOU SSRF).
|
||||
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
|
||||
try {
|
||||
return await fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
dispatcher,
|
||||
});
|
||||
} finally {
|
||||
// destroy() — not close() — force-kills sockets and rejects any in-flight request
|
||||
await dispatcher?.destroy();
|
||||
}
|
||||
})
|
||||
return validateWebhookUrl(webhook.url)
|
||||
.then(() =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
@@ -327,11 +302,9 @@ export const POST = async (request: Request) => {
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
const workspaceId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
|
||||
captureSurveyResponsePostHogEvent({
|
||||
organizationId: organization.id,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
surveyType: survey.type,
|
||||
environmentId,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -87,18 +87,10 @@ export const GET = async (req: Request) => {
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(session.user.id, "integration_connected", {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { authenticateRequest, handleErrorResponse } from "./auth";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
@@ -177,7 +171,7 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null by default when API key has no environment permissions", async () => {
|
||||
test("returns null when API key has no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
@@ -198,120 +192,4 @@ describe("authenticateRequest", () => {
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("authenticates a valid API key with no environment permissions when explicitly allowed", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all" as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
});
|
||||
});
|
||||
|
||||
test("authenticates a read-only organization API key with no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
|
||||
headers: { "x-api-key": "read-only-org-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Read-only Organization API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleErrorResponse", () => {
|
||||
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
|
||||
const response = handleErrorResponse(new Error("NotAuthenticated"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
});
|
||||
|
||||
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
|
||||
const response = handleErrorResponse(new Error("Unauthorized"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("unauthorized");
|
||||
});
|
||||
|
||||
test("returns 409 conflict for UniqueConstraintError", async () => {
|
||||
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
|
||||
expect(response.status).toBe(409);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("conflict");
|
||||
expect(body.message).toBe("Action with name foo already exists");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for DatabaseError", async () => {
|
||||
const response = handleErrorResponse(new DatabaseError("db boom"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("db boom");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for InvalidInputError", async () => {
|
||||
const response = handleErrorResponse(new InvalidInputError("bad input"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
const response = handleErrorResponse(new Error("something else"));
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Some error occurred");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
type AuthenticateApiKeyOptions = {
|
||||
allowOrganizationOnlyApiKey?: boolean;
|
||||
};
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
|
||||
export const authenticateApiKey = async (
|
||||
apiKey: string,
|
||||
options: AuthenticateApiKeyOptions = {}
|
||||
): Promise<TAuthenticationApiKey | null> => {
|
||||
// Get API key with permissions
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
if (!apiKeyData) return null;
|
||||
|
||||
if (!options.allowOrganizationOnlyApiKey && apiKeyData.apiKeyEnvironments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In the route handlers, we'll do more specific permission checks
|
||||
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||
if (environmentIds.length === 0) return null;
|
||||
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
@@ -42,16 +33,6 @@ export const authenticateApiKey = async (
|
||||
return authentication;
|
||||
};
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: NextRequest,
|
||||
options: AuthenticateApiKeyOptions = {}
|
||||
): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
|
||||
return authenticateApiKey(apiKey, options);
|
||||
};
|
||||
|
||||
export const handleErrorResponse = (error: any): Response => {
|
||||
switch (error.message) {
|
||||
case "NotAuthenticated":
|
||||
@@ -59,9 +40,6 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
case "Unauthorized":
|
||||
return responses.unauthorizedResponse();
|
||||
default:
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
|
||||
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
// name intentionally omitted — internal label not needed by the SDK
|
||||
name: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
@@ -107,13 +107,13 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
styling: true,
|
||||
status: true,
|
||||
recaptcha: true,
|
||||
// Fetch only what's needed to compute the minimal segment shape.
|
||||
// Titles, descriptions, and filter conditions are evaluated server-side
|
||||
// and must not be sent to the browser.
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
filters: true,
|
||||
include: {
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recontactDays: true,
|
||||
@@ -147,28 +147,10 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
throw new ResourceNotFoundError("project", null);
|
||||
}
|
||||
|
||||
// Transform surveys using the shared utility, then replace the segment with
|
||||
// the minimal public shape (id + hasFilters). We null out segment before
|
||||
// calling transformPrismaSurvey because that function expects a surveys[]
|
||||
// relation on the segment object (used by the management API), which we
|
||||
// intentionally don't fetch here.
|
||||
const transformedSurveys = environmentData.surveys.map((survey) => {
|
||||
const minimalSegment = survey.segment
|
||||
? {
|
||||
id: survey.segment.id,
|
||||
hasFilters:
|
||||
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
const { segment: _segment, ...surveyWithoutSegment } = survey;
|
||||
const transformed = transformPrismaSurvey<TJsEnvironmentStateSurvey>({
|
||||
...surveyWithoutSegment,
|
||||
segment: null,
|
||||
});
|
||||
|
||||
return { ...transformed, segment: minimalSegment };
|
||||
});
|
||||
// Transform surveys using existing utility
|
||||
const transformedSurveys = environmentData.surveys.map((survey) =>
|
||||
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
|
||||
);
|
||||
|
||||
return {
|
||||
environment: {
|
||||
|
||||
+5
-21
@@ -10,11 +10,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
|
||||
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
|
||||
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
@@ -27,9 +22,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
environment: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -171,7 +163,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
// Default mocks for successful retrieval
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -338,18 +329,11 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
await getEnvironmentState(environmentId);
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
environmentId,
|
||||
"app_connected",
|
||||
{
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
organization_id: "test-org-id",
|
||||
workspace_id: "test-project-id",
|
||||
},
|
||||
{ organizationId: "test-org-id", workspaceId: "test-project-id" }
|
||||
);
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("should not capture app_connected event when app setup already completed", async () => {
|
||||
|
||||
@@ -33,25 +33,11 @@ export const getEnvironmentState = async (
|
||||
});
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
const workspaceId = environment.project.id;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { organizationId: true },
|
||||
capturePostHogEvent(environmentId, "app_connected", {
|
||||
num_surveys: surveys.length,
|
||||
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
|
||||
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
|
||||
});
|
||||
const organizationId = project?.organizationId;
|
||||
|
||||
capturePostHogEvent(
|
||||
environmentId,
|
||||
"app_connected",
|
||||
{
|
||||
num_surveys: surveys.length,
|
||||
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
|
||||
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
|
||||
organization_id: organizationId ?? "",
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
organizationId ? { organizationId, workspaceId } : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
applyRateLimit: vi.fn(),
|
||||
getSurvey: vi.fn(),
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getBiggerUploadFileSizePermission: vi.fn(),
|
||||
getSignedUrlForUpload: vi.fn(),
|
||||
getErrorResponseFromStorageError: vi.fn(),
|
||||
reportApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: mocks.applyIPRateLimit,
|
||||
applyRateLimit: mocks.applyRateLimit,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: mocks.getSurvey,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: mocks.getOrganizationByEnvironmentId,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getBiggerUploadFileSizePermission: mocks.getBiggerUploadFileSizePermission,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/service", () => ({
|
||||
getSignedUrlForUpload: mocks.getSignedUrlForUpload,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/utils", () => ({
|
||||
getErrorResponseFromStorageError: mocks.getErrorResponseFromStorageError,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
||||
reportApiError: mocks.reportApiError,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
MAX_FILE_UPLOAD_SIZES: {
|
||||
standard: 1024 * 1024 * 10,
|
||||
big: 1024 * 1024 * 1024,
|
||||
},
|
||||
SENTRY_DSN: undefined,
|
||||
}));
|
||||
|
||||
const ENVIRONMENT_ID = "cm1ubebtj000614kqe4hs3c67";
|
||||
const OTHER_ENVIRONMENT_ID = "cm1ubebtj000714kqe4hs3c68";
|
||||
const SURVEY_ID = "cm1ubebtj000814kqe4hs3c69";
|
||||
const ORGANIZATION_ID = "cm1ubebtj000914kqe4hs3c70";
|
||||
|
||||
const createMockRequest = ({
|
||||
apiVersion = "v1",
|
||||
body = {
|
||||
fileName: "upload.png",
|
||||
fileType: "image/png",
|
||||
surveyId: SURVEY_ID,
|
||||
},
|
||||
environmentId = ENVIRONMENT_ID,
|
||||
}: {
|
||||
apiVersion?: "v1" | "v2";
|
||||
body?: unknown;
|
||||
environmentId?: string;
|
||||
} = {}): NextRequest => {
|
||||
const pathname = `/api/${apiVersion}/client/${environmentId}/storage`;
|
||||
|
||||
return {
|
||||
method: "POST",
|
||||
url: `https://api.test${pathname}`,
|
||||
headers: {
|
||||
get: vi.fn(() => null),
|
||||
},
|
||||
nextUrl: {
|
||||
pathname,
|
||||
},
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
const createRouteProps = (environmentId = ENVIRONMENT_ID) => ({
|
||||
params: Promise.resolve({ environmentId }),
|
||||
});
|
||||
|
||||
describe("api/v1 client storage route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.applyIPRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.applyRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.getSurvey.mockResolvedValue({ id: SURVEY_ID, environmentId: ENVIRONMENT_ID });
|
||||
mocks.getOrganizationByEnvironmentId.mockResolvedValue({ id: ORGANIZATION_ID });
|
||||
mocks.getBiggerUploadFileSizePermission.mockResolvedValue(false);
|
||||
mocks.getSignedUrlForUpload.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
presignedFields: { key: "value" },
|
||||
fileUrl: `/storage/${ENVIRONMENT_ID}/private/upload--fid--uuid.png`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("applies IP and environment rate limits before signing the upload", async () => {
|
||||
const { POST } = await import("./route");
|
||||
|
||||
const response = await POST(createMockRequest(), createRouteProps());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
presignedFields: { key: "value" },
|
||||
fileUrl: `/storage/${ENVIRONMENT_ID}/private/upload--fid--uuid.png`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.storage.upload);
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
|
||||
rateLimitConfigs.storage.uploadPerEnvironment,
|
||||
ENVIRONMENT_ID
|
||||
);
|
||||
expect(mocks.getSignedUrlForUpload).toHaveBeenCalledWith(
|
||||
"upload.png",
|
||||
ENVIRONMENT_ID,
|
||||
"image/png",
|
||||
"private",
|
||||
1024 * 1024 * 10
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 429 with CORS when the environment rate limit is exceeded", async () => {
|
||||
const { POST } = await import("./route");
|
||||
mocks.applyRateLimit.mockRejectedValueOnce(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const response = await POST(createMockRequest(), createRouteProps());
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(await response.json()).toEqual({
|
||||
code: "too_many_requests",
|
||||
message: "Maximum number of requests reached. Please try again later.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.getSignedUrlForUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not burn environment quota when the survey belongs to another environment", async () => {
|
||||
const { POST } = await import("./route");
|
||||
mocks.getSurvey.mockResolvedValueOnce({ id: SURVEY_ID, environmentId: OTHER_ENVIRONMENT_ID });
|
||||
|
||||
const response = await POST(createMockRequest(), createRouteProps());
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mocks.applyRateLimit).not.toHaveBeenCalled();
|
||||
expect(mocks.getSignedUrlForUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applies the same environment rate limit through the v2 storage re-export", async () => {
|
||||
const { POST } = await import("@/app/api/v2/client/[environmentId]/storage/route");
|
||||
|
||||
const response = await POST(createMockRequest({ apiVersion: "v2" }), createRouteProps());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.storage.upload);
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
|
||||
rateLimitConfigs.storage.uploadPerEnvironment,
|
||||
ENVIRONMENT_ID
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
|
||||
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSignedUrlForUpload } from "@/modules/storage/service";
|
||||
@@ -80,17 +79,6 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.storage.uploadPerEnvironment, environmentId);
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
|
||||
const maxFileUploadSize = isBiggerFileUploadAllowed
|
||||
? MAX_FILE_UPLOAD_SIZES.big
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const getEmail = async (token: string) => {
|
||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||
@@ -95,18 +95,10 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -101,18 +101,10 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL }
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -109,18 +109,10 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -80,11 +80,6 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.successResponse(actionClass),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return {
|
||||
response: responses.conflictResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { EnvironmentType } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { buildApiKeyMeResponse } from "./api-key-response";
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [],
|
||||
};
|
||||
|
||||
const environmentPermission = (
|
||||
environmentId: string,
|
||||
permission: "read" | "write" | "manage" = "read"
|
||||
): TAuthenticationApiKey["environmentPermissions"][number] => ({
|
||||
environmentId,
|
||||
permission,
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
});
|
||||
|
||||
describe("buildApiKeyMeResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns auth metadata for an organization-read API key without environment permissions", async () => {
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
environmentPermissions: [],
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns auth metadata with permissions for organization-read API keys with multiple environments", async () => {
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [
|
||||
environmentPermission("env-1", "read"),
|
||||
environmentPermission("env-2", "write"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
environmentType: EnvironmentType.development,
|
||||
permissions: "read",
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
},
|
||||
{
|
||||
environmentId: "env-2",
|
||||
environmentType: EnvironmentType.development,
|
||||
permissions: "write",
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
},
|
||||
],
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns the legacy environment response for a single environment permission", async () => {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
});
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Project Name",
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
|
||||
test("returns the legacy environment response for an organization-read API key with one environment", async () => {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
});
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Project Name",
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
|
||||
test("returns null when an API key has neither organization read nor exactly one environment", async () => {
|
||||
const response = await buildApiKeyMeResponse(baseAuthentication);
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when the single permitted environment no longer exists", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
const buildApiKeyMetadataResponse = (authentication: TAuthenticationApiKey) => ({
|
||||
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
|
||||
environmentId: permission.environmentId,
|
||||
environmentType: permission.environmentType,
|
||||
permissions: permission.permission,
|
||||
projectId: permission.projectId,
|
||||
projectName: permission.projectName,
|
||||
})),
|
||||
organizationId: authentication.organizationId,
|
||||
organizationAccess: authentication.organizationAccess,
|
||||
});
|
||||
|
||||
export const buildApiKeyMeResponse = async (
|
||||
authentication: TAuthenticationApiKey
|
||||
): Promise<Response | null> => {
|
||||
const environmentPermissionCount = authentication.environmentPermissions.length;
|
||||
|
||||
if (environmentPermissionCount !== 1) {
|
||||
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
|
||||
return Response.json(buildApiKeyMetadataResponse(authentication));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const permission = authentication.environmentPermissions[0];
|
||||
const environment = await getEnvironment(permission.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
createdAt: environment.createdAt,
|
||||
updatedAt: environment.updatedAt,
|
||||
appSetupCompleted: environment.appSetupCompleted,
|
||||
project: {
|
||||
id: permission.projectId,
|
||||
name: permission.projectName,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,13 +1,104 @@
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authenticateApiKey } from "@/app/api/v1/auth";
|
||||
import { buildApiKeyMeResponse } from "@/app/api/v1/management/me/lib/api-key-response";
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
|
||||
|
||||
const apiKeySelect = {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
lastUsedAt: true,
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
hashedKey: true,
|
||||
};
|
||||
|
||||
type ApiKeyData = {
|
||||
id: string;
|
||||
hashedKey: string;
|
||||
organizationId: string;
|
||||
lastUsedAt: Date | null;
|
||||
apiKeyEnvironments: Array<{
|
||||
permission: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
projectId: string;
|
||||
appSetupCompleted: boolean;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const v2Parsed = parseApiKeyV2(apiKey);
|
||||
|
||||
if (v2Parsed) {
|
||||
return validateV2ApiKey(v2Parsed);
|
||||
}
|
||||
|
||||
return validateLegacyApiKey(apiKey);
|
||||
};
|
||||
|
||||
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
|
||||
// Step 1: Fast SHA-256 lookup by indexed lookupHash
|
||||
const lookupHash = hashSha256(v2Parsed.secret);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: { lookupHash },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
|
||||
// Step 2: Security verification with bcrypt
|
||||
// Always perform bcrypt verification to prevent timing attacks
|
||||
// Use a control hash when API key doesn't exist to maintain constant timing
|
||||
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
|
||||
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
|
||||
|
||||
if (!apiKeyData || !isValid) return null;
|
||||
|
||||
return apiKeyData;
|
||||
};
|
||||
|
||||
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const hashedKey = hashSha256(apiKey);
|
||||
const result = await prisma.apiKey.findFirst({
|
||||
where: { hashedKey },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const checkRateLimit = async (userId: string) => {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, userId);
|
||||
@@ -19,23 +110,59 @@ const checkRateLimit = async (userId: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
const authentication = await authenticateApiKey(apiKey, { allowOrganizationOnlyApiKey: true });
|
||||
const updateApiKeyUsage = async (apiKeyId: string) => {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKeyId },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
};
|
||||
|
||||
if (!authentication) {
|
||||
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
|
||||
const env = apiKeyData.apiKeyEnvironments[0].environment;
|
||||
return Response.json({
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
appSetupCompleted: env.appSetupCompleted,
|
||||
project: {
|
||||
id: env.projectId,
|
||||
name: env.project.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
|
||||
return (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
ALLOWED_PERMISSIONS.includes(
|
||||
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
const apiKeyData = await validateApiKey(apiKey);
|
||||
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(authentication.apiKeyId);
|
||||
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
|
||||
// Fire-and-forget: update lastUsedAt in the background without blocking the response
|
||||
updateApiKeyUsage(apiKeyData.id).catch((error) => {
|
||||
console.error("Failed to update API key usage:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
const apiKeyMeResponse = await buildApiKeyMeResponse(authentication);
|
||||
|
||||
if (!apiKeyMeResponse) {
|
||||
if (!isValidApiKeyEnvironment(apiKeyData)) {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
|
||||
return apiKeyMeResponse;
|
||||
return buildEnvironmentResponse(apiKeyData);
|
||||
};
|
||||
|
||||
const handleSessionAuthentication = async () => {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentIdsByOrganizationId } from "./environment";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getEnvironmentIdsByOrganizationId", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns environment IDs for all projects in an organization", async () => {
|
||||
vi.mocked(prisma.environment.findMany).mockResolvedValue([{ id: "env-1" }, { id: "env-2" }] as any);
|
||||
|
||||
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2"]);
|
||||
expect(prisma.environment.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
project: {
|
||||
organizationId: "clh6pzwx90000e9ogjr0mf7so",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty list when the organization has no environments", async () => {
|
||||
vi.mocked(prisma.environment.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError for known Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.environment.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("rethrows unknown errors", async () => {
|
||||
const error = new Error("boom");
|
||||
vi.mocked(prisma.environment.findMany).mockRejectedValue(error);
|
||||
|
||||
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getEnvironmentIdsByOrganizationId = reactCache(
|
||||
async (organizationId: string): Promise<string[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
|
||||
try {
|
||||
const environments = await prisma.environment.findMany({
|
||||
where: {
|
||||
project: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return environments.map((environment) => environment.id);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import {
|
||||
TSurvey,
|
||||
@@ -10,8 +9,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { getEnvironmentIdsByOrganizationId } from "./environment";
|
||||
import { checkFeaturePermissions, getReadableEnvironmentIds } from "./utils";
|
||||
import { checkFeaturePermissions } from "./utils";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
@@ -36,10 +34,6 @@ vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
|
||||
}));
|
||||
|
||||
vi.mock("./environment", () => ({
|
||||
getEnvironmentIdsByOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org",
|
||||
name: "Test Organization",
|
||||
@@ -114,109 +108,6 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
|
||||
followUps: [],
|
||||
};
|
||||
|
||||
const baseAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [],
|
||||
};
|
||||
|
||||
const environmentPermission = (
|
||||
environmentId: string,
|
||||
permission: "read" | "write" | "manage"
|
||||
): TAuthenticationApiKey["environmentPermissions"][number] => ({
|
||||
environmentId,
|
||||
permission,
|
||||
environmentType: "development",
|
||||
projectId: `project-${environmentId}`,
|
||||
projectName: `Project ${environmentId}`,
|
||||
});
|
||||
|
||||
describe("getReadableEnvironmentIds", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns all organization environments when API key has organization read access", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns an empty list when an organization-read API key belongs to an organization without environments", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns all organization environments when API key has organization write access", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1"]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns de-duplicated environment permissions that allow GET without organization access", async () => {
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [
|
||||
environmentPermission("env-1", "read"),
|
||||
environmentPermission("env-2", "write"),
|
||||
environmentPermission("env-3", "manage"),
|
||||
environmentPermission("env-1", "read"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2", "env-3"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when the API key has no readable access", async () => {
|
||||
const result = await getReadableEnvironmentIds(baseAuthentication);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFeaturePermissions", () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasOrganizationAccess, hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { getEnvironmentIdsByOrganizationId } from "./environment";
|
||||
|
||||
export const getReadableEnvironmentIds = async (
|
||||
authentication: TAuthenticationApiKey
|
||||
): Promise<string[] | null> => {
|
||||
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
|
||||
return getEnvironmentIdsByOrganizationId(authentication.organizationId);
|
||||
}
|
||||
|
||||
const environmentIds = authentication.environmentPermissions
|
||||
.filter((permission) =>
|
||||
hasPermission(authentication.environmentPermissions, permission.environmentId, "GET")
|
||||
)
|
||||
.map((permission) => permission.environmentId);
|
||||
|
||||
const readableEnvironmentIds = Array.from(new Set(environmentIds));
|
||||
|
||||
return readableEnvironmentIds.length > 0 ? readableEnvironmentIds : null;
|
||||
};
|
||||
|
||||
export const checkFeaturePermissions = async (
|
||||
surveyData: TSurveyCreateInputWithEnvironmentId,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
checkFeaturePermissions,
|
||||
getReadableEnvironmentIds,
|
||||
} from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
transformBlocksToQuestions,
|
||||
@@ -20,7 +17,6 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
allowOrganizationOnlyApiKey: true,
|
||||
handler: async ({ req, authentication }) => {
|
||||
if (!authentication || !("apiKeyId" in authentication)) {
|
||||
return { response: responses.notAuthenticatedResponse() };
|
||||
@@ -31,10 +27,9 @@ export const GET = withV1ApiWrapper({
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const environmentIds = await getReadableEnvironmentIds(authentication);
|
||||
if (!environmentIds) {
|
||||
return { response: responses.unauthorizedResponse() };
|
||||
}
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ vi.mock("react", async () => {
|
||||
});
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const environmentId = "test-env-id";
|
||||
|
||||
describe("doesContactExist", () => {
|
||||
afterEach(() => {
|
||||
@@ -36,23 +35,23 @@ describe("doesContactExist", () => {
|
||||
environmentId: "test-env",
|
||||
});
|
||||
|
||||
const result = await doesContactExist(contactId, environmentId);
|
||||
const result = await doesContactExist(contactId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId, environmentId },
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false if contact does not exist in the environment", async () => {
|
||||
test("should return false if contact does not exist", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await doesContactExist(contactId, environmentId);
|
||||
const result = await doesContactExist(contactId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId, environmentId },
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const doesContactExist = reactCache(async (id: string, environmentId: string): Promise<boolean> => {
|
||||
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
environmentId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("createDisplay", () => {
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -108,14 +108,14 @@ describe("createDisplay", () => {
|
||||
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
|
||||
});
|
||||
|
||||
test("should create a display without contact if contact does not exist in the environment", async () => {
|
||||
test("should create a display even if contact does not exist", async () => {
|
||||
vi.mocked(doesContactExist).mockResolvedValue(false);
|
||||
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
|
||||
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -142,7 +142,7 @@ describe("createDisplay", () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: surveyId, environmentId },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
|
||||
const { contactId, surveyId, environmentId } = displayInput;
|
||||
|
||||
try {
|
||||
const contactExists = contactId ? await doesContactExist(contactId, environmentId) : false;
|
||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
||||
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -13,7 +13,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const environmentId = "test-env-id";
|
||||
const mockContact = {
|
||||
id: contactId,
|
||||
attributes: [
|
||||
@@ -33,10 +32,10 @@ describe("getContact", () => {
|
||||
mockContact as unknown as Awaited<ReturnType<typeof prisma.contact.findUnique>>
|
||||
);
|
||||
|
||||
const result = await getContact(contactId, environmentId);
|
||||
const result = await getContact(contactId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: contactId, environmentId },
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
@@ -56,10 +55,10 @@ describe("getContact", () => {
|
||||
test("should return null when contact is not found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getContact(contactId, environmentId);
|
||||
const result = await getContact(contactId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: contactId, environmentId },
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
|
||||
@@ -2,16 +2,9 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
type TContactAttributeResult = {
|
||||
attributeKey: {
|
||||
key: string;
|
||||
};
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const getContact = reactCache(async (contactId: string, environmentId: string) => {
|
||||
export const getContact = reactCache(async (contactId: string) => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId, environmentId },
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
@@ -27,13 +20,10 @@ export const getContact = reactCache(async (contactId: string, environmentId: st
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce(
|
||||
(acc: TContactAttributes, attr: TContactAttributeResult) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
|
||||
@@ -236,51 +236,6 @@ describe("createResponse V2", () => {
|
||||
const result = await createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient);
|
||||
expect(result.tags).toEqual([mockTag]);
|
||||
});
|
||||
|
||||
test("should create response with contact when contact belongs to the environment", async () => {
|
||||
const responseInputWithContact = {
|
||||
...mockResponseInput,
|
||||
contactId,
|
||||
};
|
||||
|
||||
const result = await createResponse(
|
||||
responseInputWithContact,
|
||||
mockTx as unknown as Prisma.TransactionClient
|
||||
);
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(mockTx.response.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
contact: { connect: { id: contactId } },
|
||||
contactAttributes: mockContact.attributes,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result.contact).toEqual({
|
||||
id: contactId,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("should create response without contact when contact is not found in the environment", async () => {
|
||||
vi.mocked(getContact).mockResolvedValue(null);
|
||||
const responseInputWithContact = {
|
||||
...mockResponseInput,
|
||||
contactId,
|
||||
};
|
||||
|
||||
const result = await createResponse(
|
||||
responseInputWithContact,
|
||||
mockTx as unknown as Prisma.TransactionClient
|
||||
);
|
||||
const createArgs = mockTx.response.create.mock.calls[0][0];
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(createArgs.data).not.toHaveProperty("contact");
|
||||
expect(createArgs.data).not.toHaveProperty("contactAttributes");
|
||||
expect(result.contact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getContact } from "./contact";
|
||||
export const createResponseWithQuotaEvaluation = async (
|
||||
responseInput: TResponseInputV2
|
||||
): Promise<TResponseWithQuotaFull> => {
|
||||
const txResponse = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const response = await createResponse(responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
@@ -101,7 +101,7 @@ export const createResponse = async (
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
contact = await getContact(contactId, environmentId);
|
||||
contact = await getContact(contactId);
|
||||
}
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "foo",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "after",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "name",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -68,20 +68,11 @@ describe("parseV3SurveysListQuery", () => {
|
||||
if (r.ok) {
|
||||
expect(r.limit).toBe(20);
|
||||
expect(r.cursor).toBeNull();
|
||||
expect(r.includeTotalCount).toBe(true);
|
||||
expect(r.sortBy).toBe("updatedAt");
|
||||
expect(r.filterCriteria).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("parses includeTotalCount=false", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&includeTotalCount=false`));
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.includeTotalCount).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("builds filter from explicit operator params", () => {
|
||||
const r = parseV3SurveysListQuery(
|
||||
params(
|
||||
@@ -111,7 +102,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "filter[createdBy][in]",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ const SUPPORTED_QUERY_PARAMS = [
|
||||
"workspaceId",
|
||||
"limit",
|
||||
"cursor",
|
||||
"includeTotalCount",
|
||||
FILTER_NAME_CONTAINS_QUERY_PARAM,
|
||||
FILTER_STATUS_IN_QUERY_PARAM,
|
||||
FILTER_TYPE_IN_QUERY_PARAM,
|
||||
@@ -54,11 +53,6 @@ const ZV3SurveysListQuery = z.object({
|
||||
workspaceId: ZId,
|
||||
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
|
||||
cursor: z.string().min(1).optional(),
|
||||
includeTotalCount: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.transform((value) => value !== "false")
|
||||
.default(true),
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
|
||||
.string()
|
||||
.max(512)
|
||||
@@ -77,7 +71,6 @@ export type TV3SurveysListQueryParseResult =
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor: TSurveyListPageCursor | null;
|
||||
includeTotalCount: boolean;
|
||||
sortBy: TSurveyListSort;
|
||||
filterCriteria: TSurveyFilterCriteria | undefined;
|
||||
}
|
||||
@@ -118,7 +111,6 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
|
||||
workspaceId: searchParams.get("workspaceId"),
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
cursor: searchParams.get("cursor")?.trim() || undefined,
|
||||
includeTotalCount: searchParams.get("includeTotalCount")?.trim() || undefined,
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
|
||||
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
|
||||
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
|
||||
@@ -161,7 +153,6 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
|
||||
workspaceId: q.workspaceId,
|
||||
limit: q.limit,
|
||||
cursor,
|
||||
includeTotalCount: q.includeTotalCount,
|
||||
sortBy,
|
||||
filterCriteria: buildFilterCriteria(q),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
@@ -257,29 +257,6 @@ describe("GET /api/v3/surveys", () => {
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
|
||||
});
|
||||
|
||||
test("skips totalCount when includeTotalCount=false", async () => {
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({
|
||||
surveys: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
const cursor = encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2026-04-15T10:00:00.000Z",
|
||||
id: "survey_1",
|
||||
});
|
||||
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&cursor=${cursor}&includeTotalCount=false`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({ limit: 20, nextCursor: null, totalCount: null });
|
||||
expect(getSurveyCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("passes filter query to getSurveyListPage", async () => {
|
||||
const filterCriteria = { status: ["inProgress"] };
|
||||
const req = createRequest(
|
||||
|
||||
@@ -46,22 +46,21 @@ export const GET = withV3ApiWrapper({
|
||||
|
||||
const { environmentId } = authResult;
|
||||
|
||||
const surveyPagePromise = getSurveyListPage(environmentId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
});
|
||||
const totalCountPromise = parsed.includeTotalCount
|
||||
? getSurveyCount(environmentId, parsed.filterCriteria)
|
||||
: Promise.resolve(null);
|
||||
const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]);
|
||||
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
}),
|
||||
getSurveyCount(environmentId, parsed.filterCriteria),
|
||||
]);
|
||||
|
||||
return successListResponse(
|
||||
surveyPage.surveys.map(serializeV3SurveyListItem),
|
||||
surveys.map(serializeV3SurveyListItem),
|
||||
{
|
||||
limit: parsed.limit,
|
||||
nextCursor: surveyPage.nextCursor,
|
||||
nextCursor,
|
||||
totalCount,
|
||||
},
|
||||
{ requestId, cache: "private, no-store" }
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("API Response Utilities", () => {
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Something went wrong";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.internalServerErrorResponse(message, false, {}, customCache);
|
||||
const response = responses.internalServerErrorResponse(message, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
|
||||
@@ -217,7 +217,6 @@ const successResponse = (data: Object, cors: boolean = false, cache: string = "p
|
||||
const internalServerErrorResponse = (
|
||||
message: string,
|
||||
cors: boolean = false,
|
||||
details: ApiErrorResponse["details"] = {},
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
@@ -229,7 +228,7 @@ const internalServerErrorResponse = (
|
||||
{
|
||||
code: "internal_server_error",
|
||||
message,
|
||||
details,
|
||||
details: {},
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 500,
|
||||
|
||||
@@ -587,56 +587,6 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not allow organization-only API keys by default", async () => {
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/api/v1/management/action-classes" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const response = await wrapped(req, undefined);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: false });
|
||||
});
|
||||
|
||||
test("allows organization-only API keys when the route opts in", async () => {
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, allowOrganizationOnlyApiKey: true });
|
||||
const response = await wrapped(req, undefined);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAuditLogBaseObject", () => {
|
||||
|
||||
@@ -46,11 +46,6 @@ export interface TWithV1ApiWrapperParams<
|
||||
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
|
||||
*/
|
||||
unauthenticatedResponse?: (req: NextRequest) => Response;
|
||||
/**
|
||||
* Most v1 management routes are environment-scoped. Enable this only for routes that explicitly
|
||||
* support organization-only API keys.
|
||||
*/
|
||||
allowOrganizationOnlyApiKey?: boolean;
|
||||
}
|
||||
|
||||
enum ApiV1RouteTypeEnum {
|
||||
@@ -147,17 +142,16 @@ const setupAuditLog = (
|
||||
*/
|
||||
const handleAuthentication = async (
|
||||
authenticationMethod: AuthenticationMethod,
|
||||
req: NextRequest,
|
||||
allowOrganizationOnlyApiKey = false
|
||||
req: NextRequest
|
||||
): Promise<TApiV1Authentication> => {
|
||||
switch (authenticationMethod) {
|
||||
case AuthenticationMethod.ApiKey:
|
||||
return await authenticateRequest(req, { allowOrganizationOnlyApiKey });
|
||||
return await authenticateRequest(req);
|
||||
case AuthenticationMethod.Session:
|
||||
return await getServerSession(authOptions);
|
||||
case AuthenticationMethod.Both: {
|
||||
const session = await getServerSession(authOptions);
|
||||
return session ?? (await authenticateRequest(req, { allowOrganizationOnlyApiKey }));
|
||||
return session ?? (await authenticateRequest(req));
|
||||
}
|
||||
case AuthenticationMethod.None:
|
||||
return null;
|
||||
@@ -257,14 +251,7 @@ const getRouteType = (
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const {
|
||||
handler,
|
||||
action,
|
||||
targetType,
|
||||
customRateLimitConfig,
|
||||
unauthenticatedResponse,
|
||||
allowOrganizationOnlyApiKey,
|
||||
} = params;
|
||||
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
// === Audit Log Setup ===
|
||||
const saveAuditLog = action && targetType;
|
||||
@@ -283,7 +270,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
|
||||
}
|
||||
|
||||
// === Authentication ===
|
||||
const authentication = await handleAuthentication(authenticationMethod, req, allowOrganizationOnlyApiKey);
|
||||
const authentication = await handleAuthentication(authenticationMethod, req);
|
||||
|
||||
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
|
||||
if (unauthenticatedResponse) {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface PostHogGroupIdentifyProps {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
export const PostHogGroupIdentify = ({
|
||||
organizationId,
|
||||
organizationName,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
}: PostHogGroupIdentifyProps) => {
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false;
|
||||
|
||||
const applyGroups = () => {
|
||||
posthog.group("organization", organizationId, { name: organizationName });
|
||||
posthog.group("workspace", workspaceId, { name: workspaceName });
|
||||
};
|
||||
|
||||
if (posthog.__loaded) {
|
||||
applyGroups();
|
||||
return;
|
||||
}
|
||||
|
||||
// PostHogIdentify (in app layout) initialises posthog from a sibling
|
||||
// useEffect; effect order isn't guaranteed, so poll briefly until loaded.
|
||||
const intervalId = setInterval(() => {
|
||||
if (cancelledRef.current) return;
|
||||
if (posthog.__loaded) {
|
||||
applyGroups();
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
cancelledRef.current = true;
|
||||
clearInterval(intervalId);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
clearInterval(intervalId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [organizationId, organizationName, workspaceId, workspaceName]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -4,10 +4,10 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getHasNoOrganizations } from "@/lib/instance/service";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
@@ -21,7 +21,7 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateOrganizationAction)
|
||||
.action(
|
||||
withAuditLogging("created", "organization", async ({ ctx, parsedInput }) => {
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
if (!hasNoOrganizations && !isMultiOrgEnabled) {
|
||||
@@ -50,17 +50,10 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||
|
||||
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"organization_created",
|
||||
{
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
},
|
||||
{ organizationId: newOrganization.id }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "organization_created", {
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
});
|
||||
|
||||
return newOrganization;
|
||||
})
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const Page = async () => {
|
||||
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getIsFreshInstance(),
|
||||
getHasNoOrganizations(),
|
||||
]);
|
||||
|
||||
if (isFreshInstance) {
|
||||
return redirect("/setup/intro");
|
||||
}
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+33
-32
@@ -87,7 +87,6 @@ checksums:
|
||||
billing_confirmation/upgrade_successful: 57281b5f15480a026a2f67b195d69247
|
||||
c/link_expired: edccebc7cf11b1c6a54e37aedc1f1644
|
||||
c/link_expired_description: dfbafe1d9932fdb2d151e136dd71a38d
|
||||
c/link_expired_heading: edccebc7cf11b1c6a54e37aedc1f1644
|
||||
common/accepted: ea2ed23f35f8b090b5a994ac64ec588a
|
||||
common/account: 01215c12fb1cdb93bd0c84c1382bef56
|
||||
common/account_settings: df8e9882a1f5c75951f3a05ddfed72ba
|
||||
@@ -171,7 +170,6 @@ checksums:
|
||||
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/data_refreshed_successfully: 85728c61a9e1a16e46af69ddf0dbcda6
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
@@ -217,6 +215,7 @@ checksums:
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
|
||||
@@ -330,6 +329,7 @@ checksums:
|
||||
common/placeholder: 88c2c168aff12ca70148fcb5f6b4c7b1
|
||||
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
|
||||
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
|
||||
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
|
||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||
@@ -346,7 +346,6 @@ checksums:
|
||||
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
|
||||
common/read_docs: d06513c266fdd9056e0500eab838ebac
|
||||
common/recipients: f90e7f266be3f5a724858f21a9fd855e
|
||||
common/refresh: c0aec3f31be4c984bae9a482572d2857
|
||||
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
|
||||
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
|
||||
@@ -470,7 +469,7 @@ checksums:
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
|
||||
common/you_have_reached_your_monthly_response_limit_of_count: b68287d2a673228e6ee403f48aa308ca
|
||||
common/you_have_reached_your_monthly_response_limit_of: 3824db23ecc3dcd2b1787b98ccfdd5f9
|
||||
common/you_will_be_downgraded_to_the_community_edition_on_date: bff35b54c13e2c205dc4c19056261cc0
|
||||
common/your_license_has_expired_please_renew: 3f21ae4a7deab351b143b407ece58254
|
||||
emails/accept: f8cc1de4f5e3c850cfdbbc0ec831ade7
|
||||
@@ -639,6 +638,8 @@ checksums:
|
||||
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
@@ -732,7 +733,6 @@ checksums:
|
||||
environments/integrations/create_survey_warning: e35a3f72c3c31a27650fd450b7c73c11
|
||||
environments/integrations/delete_integration: ccc879ccfcf7f85bcfe09f2bc3fa0dd3
|
||||
environments/integrations/delete_integration_confirmation: be7400c43d808b6f56d98b0f435dff3c
|
||||
environments/integrations/follow_these_docs_to_configure_it: 44a7f05d88479b9e54e200c277cceb3d
|
||||
environments/integrations/google_sheet_integration_description: 346102145ef0c7b20c64b44fd4592005
|
||||
environments/integrations/google_sheets/connect_with_google_sheets: ec82132ca0a58920d9f521a0a9845dd1
|
||||
environments/integrations/google_sheets/enter_a_valid_spreadsheet_url_error: 8c9fa8bd3d7872de17c5e69c8b8333e0
|
||||
@@ -811,6 +811,7 @@ checksums:
|
||||
environments/integrations/slack/slack_reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||
environments/integrations/slack/slack_reconnect_button_description: e5a4e7f868f76b8892998025f9dd032a
|
||||
environments/integrations/slack_integration_description: 72b33dab89e4bb2cdb72082973e0a11a
|
||||
environments/integrations/to_configure_it: 157de34c0ebe43b498f670e2a7ac470a
|
||||
environments/integrations/webhook_integration_description: 9d02de29b3f2c836e14510f961cbb9cc
|
||||
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
|
||||
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
|
||||
@@ -865,7 +866,9 @@ checksums:
|
||||
environments/segments/ex_fully_activated_recurring_users: d242c30b7ad8191c19378f597e99e684
|
||||
environments/segments/ex_power_users: 1923f36e6a1f034a591640299f0b5b75
|
||||
environments/segments/filters_reset_successfully: c3893460d8c2970e43d906c272c517e8
|
||||
environments/segments/here: 462e596d55b481e23f29b5ced669624a
|
||||
environments/segments/hide_filters: 6371822c921a146ca7860a2795c94fcd
|
||||
environments/segments/identifying_users: b5f7a6c79971ada956b1c45ef8d86044
|
||||
environments/segments/invalid_segment: f03933d5c91a87f0a4e9e978b2cd27d1
|
||||
environments/segments/invalid_segment_filters: b7b4d979e52d315faecf56c3db8e66cd
|
||||
environments/segments/load_segment: 56e0e2c2a1c3f6b2035376a2240ff453
|
||||
@@ -920,20 +923,21 @@ checksums:
|
||||
environments/segments/segment_id: 875b44f7289197810baf2e1c03de5c77
|
||||
environments/segments/segment_saved_successfully: ce116c8dc576cac49753082eb2ecb413
|
||||
environments/segments/segment_updated_successfully: 2eca3ff5c645bdccd16c306d663114a1
|
||||
environments/segments/segment_used_in_other_surveys_make_changes_here: 64a5cb0e02c4868af68db022717a4f95
|
||||
environments/segments/segments_help_you_target_users_with_same_characteristics_easily: b18b75bda13fe408a467dd625611ea06
|
||||
environments/segments/target_audience: ca47151c4f0ddb348e52ec43ce15eb03
|
||||
environments/segments/this_action_resets_all_filters_in_this_survey: a7b342c25f0d3d6060bfdff38ade0682
|
||||
environments/segments/this_segment_is_used_in_other_surveys: e7976706ae6355fb84bdad64f2033a70
|
||||
environments/segments/title_is_required: e9b1d9309edf85645ab15cc90ae0a70f
|
||||
environments/segments/unknown_filter_type: 67d05a804c1ad54434c0a90f47bb0304
|
||||
environments/segments/unlock_segments_description: 5cb5aff50997e6265807a15ed1456be1
|
||||
environments/segments/unlock_segments_title: 810b329adfd0967070a20f21953d37c0
|
||||
environments/segments/user_targeting_only_available_when_identifying_users: ed7301d94ba54c003a39a57af736e458
|
||||
environments/segments/user_targeting_is_currently_only_available_when: 9785f159fb045607b62461f38e8d3aee
|
||||
environments/segments/value_cannot_be_empty: 99efd449ec19f1ecc5cf0b6807d4f315
|
||||
environments/segments/value_must_be_a_number: 87516b5c69e08741fa8a6ddf64d60deb
|
||||
environments/segments/value_must_be_positive: d17ad009f7845a6fbeddeb2aef532e10
|
||||
environments/segments/view_filters: 791cd4bacb11e3eb0ffccee131270561
|
||||
environments/segments/where: 23aecda7d27f26121b057ec7f7327069
|
||||
environments/segments/with_the_formbricks_sdk: 2b185e6242edb69e1bc6e64e10dfc02a
|
||||
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
|
||||
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
|
||||
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
|
||||
@@ -1007,8 +1011,8 @@ checksums:
|
||||
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
|
||||
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
|
||||
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
|
||||
environments/settings/billing/usage_count_of_limit_used: 0bcf3f084731f9fc7cd0b0777e720567
|
||||
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
|
||||
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
|
||||
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
|
||||
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
|
||||
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
|
||||
@@ -1065,7 +1069,7 @@ checksums:
|
||||
environments/settings/enterprise/no_credit_card_no_sales_call_just_test_it: 18f9859cdf12537b7019ecdb0a0a2b53
|
||||
environments/settings/enterprise/on_request: cf9949748c15313a8fd57bf965bec16b
|
||||
environments/settings/enterprise/organization_roles: 731d5028521c2a3a7bdbd7ed215dd861
|
||||
environments/settings/enterprise/questions_please_reach_out_to_email: 62d2e5f69a9bad47c0b975c702603caa
|
||||
environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7
|
||||
environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa
|
||||
environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c
|
||||
environments/settings/enterprise/recheck_license_instance_mismatch: 655cd1cce2f25b100439d8725c1e72f2
|
||||
@@ -1133,7 +1137,7 @@ checksums:
|
||||
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
|
||||
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
|
||||
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
|
||||
environments/settings/general/organization_name_placeholder: abcee7d91a848e573b63a763cfaf6a08
|
||||
environments/settings/general/organization_name_placeholder: fc91de3ddc89ab77f30d555778312380
|
||||
environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432
|
||||
environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6
|
||||
environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813
|
||||
@@ -1164,15 +1168,12 @@ checksums:
|
||||
environments/settings/profile/confirm_delete_my_account: b8715ff52c7109ed6b6c31a00e44013d
|
||||
environments/settings/profile/confirm_your_current_password_to_get_started: 7815146bd37e6648a6a6637bf9e0095f
|
||||
environments/settings/profile/delete_account: f5f7b88ff3122fb0d3a330e2b99d7e3d
|
||||
environments/settings/profile/delete_account_confirmation_required: 92d7d248ffde0223744364d4cc4a6e07
|
||||
environments/settings/profile/disable_two_factor_authentication: 2f5396bad4f84ad7160421021042ee12
|
||||
environments/settings/profile/disable_two_factor_authentication_description: f81ac20c3864f1a252a4813b5f9b719a
|
||||
environments/settings/profile/each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator: ecf77d8d17ccf64e1effc6ab131fea56
|
||||
environments/settings/profile/email_change_initiated: b9a69748076f2860d9124f745b75d392
|
||||
environments/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
|
||||
environments/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
|
||||
environments/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
|
||||
environments/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
|
||||
environments/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
|
||||
environments/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
|
||||
environments/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
|
||||
@@ -1183,8 +1184,6 @@ checksums:
|
||||
environments/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
|
||||
environments/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
|
||||
environments/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
|
||||
environments/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
|
||||
environments/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
|
||||
environments/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
|
||||
environments/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
|
||||
environments/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
|
||||
@@ -1193,7 +1192,6 @@ checksums:
|
||||
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
|
||||
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
|
||||
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
|
||||
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
|
||||
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
|
||||
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
|
||||
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
|
||||
@@ -1279,12 +1277,13 @@ checksums:
|
||||
environments/surveys/edit/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
|
||||
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
|
||||
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
||||
environments/surveys/edit/adjust_theme_in_look_and_feel_settings: 51372cb5ee8d3d42389bc95468866ad1
|
||||
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
||||
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
|
||||
environments/surveys/edit/all_other_answers_will_continue_to_fallback: 4c0a7ca79f7f59e523803df375f01825
|
||||
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||
environments/surveys/edit/any_is_true: 32c9f3998984fd32a2b5bc53f2d97429
|
||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||
@@ -1296,10 +1295,10 @@ checksums:
|
||||
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
|
||||
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
|
||||
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
|
||||
environments/surveys/edit/automatically_close_survey_after_n_seconds_if_no_response: 3c816c2fa92dd46a8d2ac1a8efb5b17c
|
||||
environments/surveys/edit/automatically_close_survey_after: 3e1c400a4b226c875dc8337e3b204d85
|
||||
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
|
||||
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
|
||||
environments/surveys/edit/automatically_mark_complete_after_n_responses: 36bd1ecef42ff2292f47f88f5b5cd3bc
|
||||
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
|
||||
environments/surveys/edit/back_button_label: 504551d78645d968fcee95e3dfa5586f
|
||||
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
|
||||
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
|
||||
@@ -1357,11 +1356,11 @@ checksums:
|
||||
environments/surveys/edit/columns: 14896556dc1535d70198854757f704ec
|
||||
environments/surveys/edit/company: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||
environments/surveys/edit/company_logo: aed15a185c680fb2159a75b57e24e885
|
||||
environments/surveys/edit/completed_responses: 2fc333d023c8f9d74a58b6bd61ada411
|
||||
environments/surveys/edit/concat: 9f6420a83aa45c80f53770f18d11505b
|
||||
environments/surveys/edit/conditional_logic: 8da827c6111ff1638af11d37f5a72bf7
|
||||
environments/surveys/edit/confirm_default_language: 67ee675555346c972ae1b5a630351645
|
||||
environments/surveys/edit/confirm_survey_changes: 98228b8f52285a04df33ee59b9b04648
|
||||
environments/surveys/edit/connect_formbricks_and_launch_surveys: 5c77647d9e40d68117386b8ca4826cd7
|
||||
environments/surveys/edit/contact_fields: 0d4e3f4d2eb3481aabe3ac60a692fa74
|
||||
environments/surveys/edit/contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/surveys/edit/continue_to_settings: b9853a7eedb3ae295088268fe5a44824
|
||||
@@ -1375,6 +1374,7 @@ checksums:
|
||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
|
||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
|
||||
environments/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
|
||||
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
|
||||
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
|
||||
@@ -1480,7 +1480,8 @@ checksums:
|
||||
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
|
||||
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
||||
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||
@@ -1519,7 +1520,7 @@ checksums:
|
||||
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
|
||||
environments/surveys/edit/limit_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
|
||||
environments/surveys/edit/limit_upload_file_size_to_mb: 7bf7d8c9e5f3fade66c2651746856ab9
|
||||
environments/surveys/edit/limit_upload_file_size_to: 949c48d25ae45259cc19464e95752d29
|
||||
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
|
||||
environments/surveys/edit/list: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
|
||||
@@ -1534,8 +1535,7 @@ checksums:
|
||||
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
|
||||
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
|
||||
environments/surveys/edit/max_file_size_limit_is_mb: fb130d0c2af0004264de854c2281045f
|
||||
environments/surveys/edit/max_file_size_limit_is_mb_upgrade: 23dab3e89549095a3b6f9f3bba3c9524
|
||||
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
|
||||
environments/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
|
||||
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
|
||||
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
|
||||
@@ -1647,6 +1647,8 @@ checksums:
|
||||
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
||||
environments/surveys/edit/scale: 5f55a30a5bdf8f331b56bad9c073473c
|
||||
environments/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
|
||||
environments/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
|
||||
environments/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
|
||||
environments/surveys/edit/select_field: 45665a44f7d5707506364f17f28db3bf
|
||||
environments/surveys/edit/select_or_type_value: a99c307b2cc3f9f6f893babd546d7296
|
||||
environments/surveys/edit/select_ordering: c8f632a17fe78d8b7f87e82df9351ff9
|
||||
@@ -1654,7 +1656,7 @@ checksums:
|
||||
environments/surveys/edit/select_type: fa373e47f55ff081982844a853be3a88
|
||||
environments/surveys/edit/send_survey_to_audience_who_match: 8bc5660659f6e28cc19b1961897e9878
|
||||
environments/surveys/edit/send_your_respondents_to_a_page_of_your_choice: 9b77b1afe9a81bdbbb55ae323c5a3175
|
||||
environments/surveys/edit/set_global_placement_in_look_feel_settings_hint: 18e030590220d3d8626c8f9ddb4dd855
|
||||
environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929
|
||||
environments/surveys/edit/settings_saved_successfully: 7f6833d9079e404fb3a5b0aa51fdcf17
|
||||
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
|
||||
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
|
||||
@@ -1664,7 +1666,7 @@ checksums:
|
||||
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
|
||||
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
||||
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
|
||||
environments/surveys/edit/show_survey_maximum_of_n_times: 7adce73c375fa89cf8268f2fdc02d36d
|
||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
|
||||
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
|
||||
@@ -1700,6 +1702,8 @@ checksums:
|
||||
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
||||
environments/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
|
||||
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||
environments/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
|
||||
environments/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
|
||||
environments/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
|
||||
@@ -1774,9 +1778,8 @@ checksums:
|
||||
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
|
||||
environments/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
|
||||
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
|
||||
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
||||
environments/surveys/edit/wait_n_days_before_showing_this_survey_again: e83a6536a5bd9a1b13115d8bc34ba6cf
|
||||
environments/surveys/edit/wait_n_seconds_before_showing_the_survey: 1e5ec00f0392e7640f3ce9f5a6c67e4f
|
||||
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
|
||||
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
@@ -2283,13 +2286,11 @@ checksums:
|
||||
organizations/workspaces/new/settings/workspace_settings_title: a4ec3549507071e1f7d3c834b019fcce
|
||||
s/check_inbox_or_spam: c48ac1f7b76052881bb3b6d10615152d
|
||||
s/completed: 98a9cd97b409933edf1991e7d022bea9
|
||||
s/completed_heading: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
s/create_your_own: 27976ec69029d6dd52d146a9b5765bc6
|
||||
s/enter_pin: 90bccdc2e51ee2550842287d1f02c999
|
||||
s/just_curious: d5e5c40e97fcfdab563707ab0de10862
|
||||
s/link_invalid: bd63a73fa5eecad2dfd0687cfed02114
|
||||
s/paused: 65ec150837ca033f5a0fb5d4482e0e4b
|
||||
s/paused_heading: edb1f7b7219e1c9b7aa67159090d6991
|
||||
s/please_try_again_with_the_original_link: ff903c908d199ae654436972dc0576f9
|
||||
s/preview_survey_questions: 2cbdd526f6652d5ee0337e23477d3396
|
||||
s/question_preview: 9d8fbc0150fc10ba851beba2d4f4d9f3
|
||||
@@ -2313,7 +2314,7 @@ checksums:
|
||||
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
|
||||
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
setup/invite/failed_to_invite: dc5ce0dcdf0df978f1102b729dc15584
|
||||
setup/invite/invitation_sent_to_email: 0c70bfd61508aea1d447e1a9dcb8bb8a
|
||||
setup/invite/invitation_sent_to: 5a79e59f2048ee2f8b660715dcb98191
|
||||
setup/invite/invite_your_organization_members: 98de00cb78b11297679f1565e0c24517
|
||||
setup/invite/life_s_no_fun_alone: e4e080c6957a10bf218fbd1b5d8cdecc
|
||||
setup/invite/skip: b7f28dfa2f58b80b149bb82b392d0291
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createActionClass,
|
||||
deleteActionClass,
|
||||
getActionClass,
|
||||
getActionClassByEnvironmentIdAndName,
|
||||
getActionClasses,
|
||||
updateActionClass,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -20,8 +16,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -184,147 +178,4 @@ describe("ActionClass Service", () => {
|
||||
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createActionClass", () => {
|
||||
const codeInput: TActionClassInput = {
|
||||
name: "Code Action",
|
||||
description: "desc",
|
||||
type: "code",
|
||||
key: "code-action-key",
|
||||
environmentId: "env-create",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should create and return the action class", async () => {
|
||||
const created: TActionClass = {
|
||||
id: "id-create",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: codeInput.name,
|
||||
description: codeInput.description ?? null,
|
||||
type: "code",
|
||||
key: codeInput.type === "code" ? codeInput.key : null,
|
||||
noCodeConfig: null,
|
||||
environmentId: codeInput.environmentId,
|
||||
};
|
||||
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
|
||||
|
||||
const result = await createActionClass(codeInput.environmentId, codeInput);
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Action with name ${codeInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: undefined }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for non-P2002 errors", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Database error when creating an action for environment ${codeInput.environmentId}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateActionClass", () => {
|
||||
const updateInput: Partial<TActionClassInput> = {
|
||||
name: "Renamed Action",
|
||||
description: "updated desc",
|
||||
type: "code",
|
||||
key: "renamed-key",
|
||||
environmentId: "env-update",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should update and return the action class", async () => {
|
||||
const updated = {
|
||||
id: "id-update",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: updateInput.name,
|
||||
description: updateInput.description ?? null,
|
||||
type: "code" as const,
|
||||
key: "renamed-key",
|
||||
noCodeConfig: null,
|
||||
environmentId: updateInput.environmentId,
|
||||
surveyTriggers: [],
|
||||
};
|
||||
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
|
||||
|
||||
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
`Action with name ${updateInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("should rethrow unknown errors", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
"boom"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -135,7 +135,7 @@ export const createActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new UniqueConstraintError(
|
||||
throw new DatabaseError(
|
||||
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export const updateActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new UniqueConstraintError(
|
||||
throw new DatabaseError(
|
||||
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
@@ -34,7 +33,6 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
|
||||
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
|
||||
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
|
||||
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
|
||||
@@ -155,9 +153,6 @@ export const DEBUG = env.DEBUG === "1";
|
||||
// Enterprise License constant
|
||||
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const ENTERPRISE_LICENSE_REQUEST_FORM_URL =
|
||||
"https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=ce";
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -18,31 +18,18 @@ export const selectDisplay = {
|
||||
|
||||
export const getDisplayCountBySurveyId = reactCache(
|
||||
async (surveyId: string, filters?: TDisplayFilters): Promise<number> => {
|
||||
validateInputs([surveyId, ZId], [filters, ZDisplayFilters.optional()]);
|
||||
|
||||
if (filters?.responseIds?.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
validateInputs([surveyId, ZId]);
|
||||
|
||||
try {
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId,
|
||||
surveyId: surveyId,
|
||||
...(filters?.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
...(filters?.responseIds && {
|
||||
response: {
|
||||
is: {
|
||||
id: {
|
||||
in: filters.responseIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
return displayCount;
|
||||
|
||||
@@ -4,14 +4,9 @@ import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
getDisplayCountBySurveyId,
|
||||
getDisplaysByContactId,
|
||||
getDisplaysBySurveyIdWithContact,
|
||||
} from "../service";
|
||||
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
{
|
||||
@@ -50,74 +45,6 @@ const mockDisplaysWithContact = [
|
||||
},
|
||||
];
|
||||
|
||||
describe("getDisplayCountBySurveyId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("counts displays by surveyId", async () => {
|
||||
vi.mocked(prisma.display.count).mockResolvedValue(5);
|
||||
|
||||
const result = await getDisplayCountBySurveyId(mockSurveyId);
|
||||
|
||||
expect(result).toBe(5);
|
||||
expect(prisma.display.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("combines createdAt and responseIds filters", async () => {
|
||||
const createdAt = {
|
||||
min: new Date("2024-01-01T00:00:00.000Z"),
|
||||
max: new Date("2024-01-31T23:59:59.999Z"),
|
||||
};
|
||||
vi.mocked(prisma.display.count).mockResolvedValue(2);
|
||||
|
||||
const result = await getDisplayCountBySurveyId(mockSurveyId, {
|
||||
createdAt,
|
||||
responseIds: mockResponseIds,
|
||||
});
|
||||
|
||||
expect(result).toBe(2);
|
||||
expect(prisma.display.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
createdAt: {
|
||||
gte: createdAt.min,
|
||||
lte: createdAt.max,
|
||||
},
|
||||
response: {
|
||||
is: {
|
||||
id: {
|
||||
in: mockResponseIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 0 without querying when responseIds filter is empty", async () => {
|
||||
const result = await getDisplayCountBySurveyId(mockSurveyId, { responseIds: [] });
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(prisma.display.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.count).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplayCountBySurveyId(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplaysByContactId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays for a contact ordered by createdAt desc", async () => {
|
||||
|
||||
@@ -123,7 +123,6 @@ const parsedEnv = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
@@ -137,7 +136,6 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AI_GCP_PROJECT: z.string().optional(),
|
||||
@@ -269,7 +267,6 @@ const parsedEnv = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
@@ -283,7 +280,6 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
GITHUB_ID: process.env.GITHUB_ID,
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const getIsFreshInstance = reactCache(async (): Promise<boolean> => {
|
||||
});
|
||||
|
||||
// Function to check if there are any organizations in the database
|
||||
export const getHasNoOrganizations = reactCache(async (): Promise<boolean> => {
|
||||
export const gethasNoOrganizations = reactCache(async (): Promise<boolean> => {
|
||||
try {
|
||||
const organizationCount = await prisma.organization.count();
|
||||
return organizationCount === 0;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import {
|
||||
createAccountDeletionSsoReauthIntent,
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
createInviteToken,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
getEmailFromEmailToken,
|
||||
verifyAccountDeletionSsoReauthIntent,
|
||||
verifyEmailChangeToken,
|
||||
verifyInviteToken,
|
||||
verifySsoRelinkIntent,
|
||||
@@ -1084,124 +1082,5 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("account deletion SSO reauthentication intents", () => {
|
||||
const accountDeletionIntent = {
|
||||
id: "intent-id",
|
||||
userId: mockUser.id,
|
||||
email: mockUser.email,
|
||||
provider: "google",
|
||||
providerAccountId: "provider-123",
|
||||
purpose: "account_deletion_sso_reauth" as const,
|
||||
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
|
||||
};
|
||||
|
||||
test("round-trips encrypted account deletion reauth intents", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
|
||||
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.id, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.email, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
|
||||
accountDeletionIntent.returnToUrl,
|
||||
TEST_ENCRYPTION_KEY
|
||||
);
|
||||
});
|
||||
|
||||
test("creates account deletion reauth intents with a ten minute default expiry", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
|
||||
expect(decoded.exp - decoded.iat).toBe(10 * 60);
|
||||
});
|
||||
|
||||
test("rejects account deletion reauth intents with the wrong purpose", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
|
||||
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: crypto.symmetricEncrypt(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
),
|
||||
purpose: "sso_recovery",
|
||||
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects account deletion reauth intents missing required fields", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
|
||||
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: crypto.symmetricEncrypt(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
),
|
||||
purpose: accountDeletionIntent.purpose,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects expired account deletion reauth intents", () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
|
||||
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: crypto.symmetricEncrypt(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
),
|
||||
purpose: accountDeletionIntent.purpose,
|
||||
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
|
||||
exp: Math.floor(Date.now() / 1000) - 3600,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
|
||||
});
|
||||
|
||||
test("throws when account deletion reauth intent secrets are missing", async () => {
|
||||
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: accountDeletionIntent.id,
|
||||
userId: accountDeletionIntent.userId,
|
||||
email: accountDeletionIntent.email,
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: accountDeletionIntent.providerAccountId,
|
||||
purpose: accountDeletionIntent.purpose,
|
||||
returnToUrl: accountDeletionIntent.returnToUrl,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await testMissingSecretsError(verifyAccountDeletionSsoReauthIntent, [token]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,16 +35,6 @@ type TSsoRelinkIntentPayload = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type TAccountDeletionSsoReauthIntentPayload = {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
purpose: "account_deletion_sso_reauth";
|
||||
returnToUrl: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
|
||||
|
||||
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
|
||||
@@ -272,10 +262,6 @@ const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
|
||||
expiresIn: "15m",
|
||||
};
|
||||
|
||||
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
|
||||
expiresIn: "10m",
|
||||
};
|
||||
|
||||
export const createSsoRelinkIntent = (
|
||||
payload: TSsoRelinkIntentPayload,
|
||||
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
|
||||
@@ -337,77 +323,6 @@ export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload =>
|
||||
};
|
||||
};
|
||||
|
||||
export const createAccountDeletionSsoReauthIntent = (
|
||||
payload: TAccountDeletionSsoReauthIntentPayload,
|
||||
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS
|
||||
): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
return jwt.sign(
|
||||
{
|
||||
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
|
||||
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
|
||||
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
purpose: payload.purpose,
|
||||
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
|
||||
},
|
||||
NEXTAUTH_SECRET,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const verifyAccountDeletionSsoReauthIntent = (
|
||||
token: string
|
||||
): TAccountDeletionSsoReauthIntentPayload => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
purpose: string;
|
||||
returnToUrl: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!payload?.id ||
|
||||
!payload?.userId ||
|
||||
!payload?.email ||
|
||||
!payload?.provider ||
|
||||
!payload?.providerAccountId ||
|
||||
payload?.purpose !== "account_deletion_sso_reauth" ||
|
||||
!payload?.returnToUrl
|
||||
) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
|
||||
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
|
||||
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
purpose: "account_deletion_sso_reauth",
|
||||
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
|
||||
import { capturePostHogEvent } from "./capture";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capture: vi.fn(),
|
||||
groupIdentify: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -14,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server", () => ({
|
||||
posthogServerClient: { capture: mocks.capture, groupIdentify: mocks.groupIdentify },
|
||||
posthogServerClient: { capture: mocks.capture },
|
||||
}));
|
||||
|
||||
describe("capturePostHogEvent", () => {
|
||||
@@ -33,7 +32,6 @@ describe("capturePostHogEvent", () => {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,41 +45,6 @@ describe("capturePostHogEvent", () => {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("includes organization and workspace groups when provided", () => {
|
||||
capturePostHogEvent(
|
||||
"user123",
|
||||
"test_event",
|
||||
{ key: "value" },
|
||||
{ organizationId: "org_1", workspaceId: "ws_1" }
|
||||
);
|
||||
|
||||
expect(mocks.capture).toHaveBeenCalledWith({
|
||||
distinctId: "user123",
|
||||
event: "test_event",
|
||||
properties: {
|
||||
key: "value",
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: { organization: "org_1", workspace: "ws_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("includes only organization group when workspaceId missing", () => {
|
||||
capturePostHogEvent("user123", "test_event", undefined, { organizationId: "org_1" });
|
||||
|
||||
expect(mocks.capture).toHaveBeenCalledWith({
|
||||
distinctId: "user123",
|
||||
event: "test_event",
|
||||
properties: {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: { organization: "org_1" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,44 +61,6 @@ describe("capturePostHogEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupIdentifyPostHog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("calls posthog groupIdentify with correct params", () => {
|
||||
groupIdentifyPostHog("organization", "org_1", { name: "Acme" });
|
||||
|
||||
expect(mocks.groupIdentify).toHaveBeenCalledWith({
|
||||
groupType: "organization",
|
||||
groupKey: "org_1",
|
||||
properties: { name: "Acme" },
|
||||
});
|
||||
});
|
||||
|
||||
test("identifies workspace group", () => {
|
||||
groupIdentifyPostHog("workspace", "ws_1", { name: "Marketing" });
|
||||
|
||||
expect(mocks.groupIdentify).toHaveBeenCalledWith({
|
||||
groupType: "workspace",
|
||||
groupKey: "ws_1",
|
||||
properties: { name: "Marketing" },
|
||||
});
|
||||
});
|
||||
|
||||
test("does not throw when groupIdentify throws", () => {
|
||||
mocks.groupIdentify.mockImplementation(() => {
|
||||
throw new Error("Network error");
|
||||
});
|
||||
|
||||
expect(() => groupIdentifyPostHog("organization", "org_1")).not.toThrow();
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error), groupType: "organization", groupKey: "org_1" },
|
||||
"Failed to identify PostHog group"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capturePostHogEvent with null client", () => {
|
||||
test("no-ops when posthogServerClient is null", async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -149,14 +74,11 @@ describe("capturePostHogEvent with null client", () => {
|
||||
posthogServerClient: null,
|
||||
}));
|
||||
|
||||
const { capturePostHogEvent: captureWithNullClient, groupIdentifyPostHog: identifyWithNullClient } =
|
||||
await import("./capture");
|
||||
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
|
||||
|
||||
captureWithNullClient("user123", "test_event", { key: "value" });
|
||||
identifyWithNullClient("organization", "org_1");
|
||||
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
expect(mocks.groupIdentify).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,24 +4,10 @@ import { posthogServerClient } from "./server";
|
||||
|
||||
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
export type PostHogGroupContext = {
|
||||
organizationId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
const buildGroups = (context?: PostHogGroupContext): Record<string, string> | undefined => {
|
||||
if (!context) return undefined;
|
||||
const groups: Record<string, string> = {};
|
||||
if (context.organizationId) groups.organization = context.organizationId;
|
||||
if (context.workspaceId) groups.workspace = context.workspaceId;
|
||||
return Object.keys(groups).length > 0 ? groups : undefined;
|
||||
};
|
||||
|
||||
export function capturePostHogEvent(
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties?: PostHogEventProperties,
|
||||
groupContext?: PostHogGroupContext
|
||||
properties?: PostHogEventProperties
|
||||
): void {
|
||||
if (!posthogServerClient) return;
|
||||
|
||||
@@ -34,29 +20,8 @@ export function capturePostHogEvent(
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: buildGroups(groupContext),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, eventName }, "Failed to capture PostHog event");
|
||||
}
|
||||
}
|
||||
|
||||
type PostHogGroupType = "organization" | "workspace";
|
||||
|
||||
export function groupIdentifyPostHog(
|
||||
groupType: PostHogGroupType,
|
||||
groupKey: string,
|
||||
properties?: Record<string, string | number | boolean | null | undefined>
|
||||
): void {
|
||||
if (!posthogServerClient) return;
|
||||
|
||||
try {
|
||||
posthogServerClient.groupIdentify({
|
||||
groupType,
|
||||
groupKey,
|
||||
properties,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, groupType, groupKey }, "Failed to identify PostHog group");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getFeatureFlag: vi.fn(),
|
||||
posthog: {
|
||||
__loaded: false,
|
||||
getFeatureFlag: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: mocks.posthog,
|
||||
}));
|
||||
|
||||
describe("getPostHogClientFeatureFlag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.posthog.__loaded = false;
|
||||
mocks.posthog.getFeatureFlag = mocks.getFeatureFlag;
|
||||
});
|
||||
|
||||
test("returns false before PostHog is initialized", async () => {
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns true from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(true);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(false);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns variant string from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue("variant-a");
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe("variant-a");
|
||||
});
|
||||
|
||||
test("coerces undefined to false", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(undefined);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import type { TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
export const getPostHogClientFeatureFlag = (flagKey: string): TPostHogFeatureFlagValue => {
|
||||
if (!posthog.__loaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const featureFlagValue = posthog.getFeatureFlag(flagKey);
|
||||
return featureFlagValue ?? false;
|
||||
};
|
||||
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
@@ -1,131 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getFeatureFlag: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getPostHogFeatureFlag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("returns false when PostHog is not configured", async () => {
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: undefined }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns false when posthogServerClient is null", async () => {
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: null,
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("forwards distinctId, flagKey, and mapped groups to PostHog", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(
|
||||
getPostHogFeatureFlag("user123", "experiment-flag", {
|
||||
organizationId: "org_123",
|
||||
workspaceId: "ws_456",
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mocks.getFeatureFlag).toHaveBeenCalledWith("experiment-flag", "user123", {
|
||||
groups: {
|
||||
organization: "org_123",
|
||||
workspace: "ws_456",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves variant string responses", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue("variant-a");
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe("variant-a");
|
||||
});
|
||||
|
||||
test("coerces undefined to false", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("logs and returns false when PostHog throws", async () => {
|
||||
mocks.getFeatureFlag.mockRejectedValue(new Error("network error"));
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error), flagKey: "experiment-flag" },
|
||||
"Failed to evaluate PostHog feature flag"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { posthogServerClient } from "./server";
|
||||
import type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
const buildPostHogGroups = (context?: TPostHogFeatureFlagContext): Record<string, string> | undefined => {
|
||||
const groups = {
|
||||
...(context?.organizationId ? { organization: context.organizationId } : {}),
|
||||
...(context?.workspaceId ? { workspace: context.workspaceId } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(groups).length > 0 ? groups : undefined;
|
||||
};
|
||||
|
||||
export const getPostHogFeatureFlag = async (
|
||||
distinctId: string,
|
||||
flagKey: string,
|
||||
context?: TPostHogFeatureFlagContext
|
||||
): Promise<TPostHogFeatureFlagValue> => {
|
||||
if (!POSTHOG_KEY || !posthogServerClient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const featureFlagValue = await posthogServerClient.getFeatureFlag(flagKey, distinctId, {
|
||||
groups: buildPostHogGroups(context),
|
||||
});
|
||||
|
||||
return featureFlagValue ?? false;
|
||||
} catch (error) {
|
||||
logger.warn({ error, flagKey }, "Failed to evaluate PostHog feature flag");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1 @@
|
||||
import "server-only";
|
||||
|
||||
export { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
|
||||
export type { PostHogGroupContext } from "./capture";
|
||||
export { getPostHogFeatureFlag } from "./get-feature-flag";
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
export { capturePostHogEvent } from "./capture";
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export type TPostHogFeatureFlagValue = boolean | string;
|
||||
|
||||
export type TPostHogFeatureFlagContext = {
|
||||
organizationId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
/**
|
||||
* Adapts a full management-side `TSurvey` into the minimal
|
||||
* `TJsEnvironmentStateSurvey` shape that the SDK widget / shared SDK utilities
|
||||
* expect. Only the segment shape needs reshaping — the rest of `TSurvey` is a
|
||||
* structural superset of the SDK survey type.
|
||||
*/
|
||||
export const toJsEnvironmentStateSurvey = (survey: TSurvey): TJsEnvironmentStateSurvey => {
|
||||
return {
|
||||
...survey,
|
||||
segment: survey.segment ? { id: survey.segment.id, hasFilters: survey.segment.filters.length > 0 } : null,
|
||||
} as unknown as TJsEnvironmentStateSurvey;
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
import { getUserAuthenticationData, verifyUserPassword } from "./password";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPassword = vi.mocked(verifyPassword);
|
||||
|
||||
describe("user password helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("returns authentication data for an existing user", async () => {
|
||||
const user = {
|
||||
email: "user@example.com",
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
};
|
||||
mockPrismaUserFindUnique.mockResolvedValue(user as any);
|
||||
|
||||
await expect(getUserAuthenticationData("user-with-password")).resolves.toEqual(user);
|
||||
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "user-with-password",
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when authentication data is missing", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(getUserAuthenticationData("missing-user")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("verifies a password against the stored hash", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
email: "password-user@example.com",
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
} as any);
|
||||
mockVerifyPassword.mockResolvedValue(true);
|
||||
|
||||
await expect(verifyUserPassword("password-user", "plain-password")).resolves.toBe(true);
|
||||
|
||||
expect(mockVerifyPassword).toHaveBeenCalledWith("plain-password", "hashed-password");
|
||||
});
|
||||
|
||||
test("returns false when the password does not match the stored hash", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
email: "password-user@example.com",
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
} as any);
|
||||
mockVerifyPassword.mockResolvedValue(false);
|
||||
|
||||
await expect(verifyUserPassword("password-user", "wrong-password")).resolves.toBe(false);
|
||||
|
||||
expect(mockVerifyPassword).toHaveBeenCalledWith("wrong-password", "hashed-password");
|
||||
});
|
||||
|
||||
test("rejects password verification for users without a password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
email: "sso-user@example.com",
|
||||
identityProvider: "google",
|
||||
identityProviderAccountId: "google-account-id",
|
||||
password: null,
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword("sso-user", "plain-password")).rejects.toThrow(InvalidInputError);
|
||||
|
||||
expect(mockVerifyPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import "server-only";
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
export const getUserAuthenticationData = reactCache(
|
||||
async (
|
||||
userId: string
|
||||
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserAuthenticationData(userId);
|
||||
|
||||
if (!user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
return await verifyPassword(password, user.password);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user