Compare commits

..

13 Commits

Author SHA1 Message Date
Matthias Nannt
b5610c8128 fix: remove vulnerable libraries from Dockerimage 2025-05-05 23:48:35 +02:00
Matti Nannt
3b1cddb9ce chore: upgrade npm dependencies in apps/web (#5669) 2025-05-05 23:44:14 +02:00
Matti Nannt
bd22aaaa86 chore: move js sdk and demo app to its own repository (#5668) 2025-05-05 21:03:54 +02:00
Piyush Gupta
e0e42d2eed fix: adds support for default_team_id env var (#5046)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 18:40:19 +00:00
Matti Nannt
616210f1bf chore: update outdated dependencies (#5666) 2025-05-05 19:34:56 +02:00
Matti Nannt
ff2e7f6cc7 chore: make microsoft login available without tenant (#5665) 2025-05-05 19:18:07 +02:00
Jakob Schott
d1ce037f7d feat: disable productionbrowsersourcemaps (#5663) 2025-05-05 16:55:05 +00:00
Matti Nannt
91f87f4b7b chore: upgrade prisma to 6.7.0 (#5664) 2025-05-05 18:38:59 +02:00
Dhruwang Jariwala
61657b9f9a chore: add char limit to other option (#5382)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 14:56:54 +00:00
Dhruwang Jariwala
476d032642 fix: regex DoS issues (#5520)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-05 12:11:51 +00:00
Johannes
7538e570c5 chore: enforce cookie options for more security (#5618) 2025-05-05 12:09:35 +00:00
Jakob Schott
66fcf4b79b fix: changed unnecessary map to forEach (#5490) 2025-05-05 14:12:34 +02:00
Dhruwang Jariwala
21371b1815 fix: weak cryptography security hotspot (#5600) 2025-05-05 11:41:56 +00:00
109 changed files with 3280 additions and 3744 deletions

View File

@@ -93,10 +93,6 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
# Signup. Disable the ability for new users to create an account.
# Note: This variable is only available to the SaaS setup of Formbricks Cloud. Signup is disable by default for self-hosting.
# SIGNUP_DISABLED=1
# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1
@@ -158,10 +154,6 @@ NOTION_OAUTH_CLIENT_SECRET=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=
GOOGLE_SHEETS_CLIENT_SECRET=
@@ -180,8 +172,9 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
# Send new users to Brevo
# BREVO_API_KEY=
@@ -224,4 +217,4 @@ UNKEY_ROOT_KEY=
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT
# DISABLE_USER_MANAGEMENT=1

View File

@@ -1,5 +0,0 @@
NEXT_PUBLIC_FORMBRICKS_API_HOST=http://localhost:3000
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=YOUR_ENVIRONMENT_ID
# Copy the environment ID for the URL of your Formbricks App and
# paste it above to connect your Formbricks App with the Demo App.

View File

@@ -1,7 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/next.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

36
apps/demo/.gitignore vendored
View File

@@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,13 +0,0 @@
import { Sidebar } from "./sidebar";
export function LayoutApp({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<div className="min-h-full">
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<Sidebar />
</div>
<div className="flex flex-1 flex-col lg:pl-64">{children}</div>
</div>
);
}

View File

@@ -1,65 +0,0 @@
import {
ClockIcon,
CogIcon,
CreditCardIcon,
FileBarChartIcon,
HelpCircleIcon,
HomeIcon,
ScaleIcon,
ShieldCheckIcon,
UsersIcon,
} from "lucide-react";
import { classNames } from "../lib/utils";
const navigation = [
{ name: "Home", href: "#", icon: HomeIcon, current: true },
{ name: "History", href: "#", icon: ClockIcon, current: false },
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
{ name: "Recipients", href: "#", icon: UsersIcon, current: false },
{ name: "Reports", href: "#", icon: FileBarChartIcon, current: false },
];
const secondaryNavigation = [
{ name: "Settings", href: "#", icon: CogIcon },
{ name: "Help", href: "#", icon: HelpCircleIcon },
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
];
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
<div className="space-y-1 px-2">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
<div className="mt-6 pt-6">
<div className="space-y-1 px-2">
{secondaryNavigation.map((item) => (
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
</div>
</nav>
</div>
);
}

View File

@@ -1,23 +0,0 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

View File

@@ -1,3 +0,0 @@
export function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@@ -1,17 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "tailwindui.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;

View File

@@ -1,28 +0,0 @@
{
"name": "@formbricks/demo",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
"dev": "next dev -p 3002 --turbopack",
"go": "next dev -p 3002 --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*"
}
}

View File

@@ -1,20 +0,0 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import "../globals.css";
export default function App({ Component, pageProps }: AppProps): React.JSX.Element {
return (
<>
<Head>
<title>Demo App</title>
</Head>
{(!process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID ||
!process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) && (
<div className="w-full bg-red-500 p-3 text-center text-sm text-white">
Please set Formbricks environment variables in apps/demo/.env
</div>
)}
<Component {...pageProps} />
</>
);
}

View File

@@ -1,13 +0,0 @@
import { Head, Html, Main, NextScript } from "next/document";
export default function Document(): React.JSX.Element {
return (
<Html lang="en" className="h-full bg-slate-50">
<Head />
<body className="h-full">
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@@ -1,359 +0,0 @@
import Image from "next/image";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import formbricks from "@formbricks/js";
import fbsetup from "../public/fb-setup.png";
declare const window: Window;
export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userAttributes = {
"Attribute 1": "one",
"Attribute 2": "two",
"Attribute 3": "three",
};
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
useEffect(() => {
const initFormbricks = () => {
// enable Formbricks debug mode by adding formbricksDebug=true GET parameter
const addFormbricksDebugParam = (): void => {
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has("formbricksDebug")) {
urlParams.set("formbricksDebug", "true");
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
window.history.replaceState({}, "", newUrl);
}
};
addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
void formbricks.setup({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
});
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks.registerRouteChange;
router.events.on("routeChangeComplete", () => {
void handleRouteChange();
});
return () => {
router.events.off("routeChangeComplete", () => {
void handleRouteChange();
});
};
}
};
initFormbricks();
}, [router.events]);
return (
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
</div>
<button
type="button"
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => {
setDarkMode(!darkMode);
}}>
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white">
Set a user ID / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
the local state gets <strong>updated with the user state</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
type="button"
onClick={() => {
void formbricks.setUserId(userId);
}}>
Set user ID
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
No-Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
No Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Free");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Free&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Free&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Paid");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Paid&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Paid&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setEmail("test@web.com");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Email
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user email
</a>{" "}
&apos;test@web.com&apos;
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttributes(userAttributes);
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Multiple Attributes
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user attributes
</a>{" "}
to &apos;one&apos;, &apos;two&apos;, &apos;three&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setLanguage("de");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Language to &apos;de&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
language
</a>{" "}
to &apos;de&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.track("code");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.logout();
}}>
Logout
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button logs out the user and syncs the local state with Formbricks. (Only works if a
userId is set)
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

View File

@@ -1,5 +0,0 @@
{
"exclude": ["node_modules"],
"extends": "@formbricks/config-typescript/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

View File

@@ -11,13 +11,12 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.19",
"eslint-plugin-react-refresh": "0.4.20",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
@@ -27,14 +26,13 @@
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.5"
"vite": "6.3.5"
}
}

View File

@@ -79,17 +79,10 @@ RUN npm install -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
# In the runner stage
RUN apk update && \
apk upgrade && \
# This explicitly removes old package versions
rm -rf /var/cache/apk/*
WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
@@ -135,8 +128,7 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
@@ -162,12 +154,6 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
CMD (cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js

View File

@@ -1,6 +1,5 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
@@ -125,7 +124,6 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -1,81 +0,0 @@
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
// Mock next/navigation hooks.
vi.mock("next/navigation", () => ({
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
}));
// Mock the Formbricks SDK module.
vi.mock("@formbricks/js", () => ({
__esModule: true,
default: {
setup: vi.fn(),
setUserId: vi.fn(),
setEmail: vi.fn(),
registerRouteChange: vi.fn(),
},
}));
describe("FormbricksClient", () => {
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
environmentId: "env-test",
appUrl: "https://api.test.com",
});
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
// And the second effect should always register the route change when Formbricks is enabled.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();
expect(mockSetUserId).not.toHaveBeenCalled();
expect(mockSetEmail).not.toHaveBeenCalled();
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
});

View File

@@ -1,44 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js";
interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.setup({
environmentId: formbricksEnvironmentId ?? "",
appUrl: formbricksApiHost ?? "",
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
useEffect(() => {
if (formbricksEnabled) {
formbricks.registerRouteChange();
}
}, [pathname, searchParams, formbricksEnabled]);
return null;
};

View File

@@ -2,7 +2,6 @@
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -265,7 +264,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -392,7 +391,6 @@ export const MainNavigation = ({
onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
await formbricksLogout();
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -26,13 +26,6 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("../../components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
}));

View File

@@ -1,6 +1,5 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -37,7 +36,6 @@ export const DeleteAccount = ({
setOpen={setModalOpen}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
formbricksLogout={formbricksLogout}
organizationsWithSingleOwner={organizationsWithSingleOwner}
/>
<p className="text-sm text-slate-700">

View File

@@ -409,7 +409,7 @@ export const getQuestionSummary = async (
}
});
Object.entries(choiceCountMap).map(([label, count]) => {
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
value: label,
count,
@@ -508,7 +508,7 @@ export const getQuestionSummary = async (
}
});
Object.entries(choiceCountMap).map(([label, count]) => {
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: parseInt(label),
count,

View File

@@ -36,14 +36,10 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
}));
@@ -74,17 +70,5 @@ describe("(app) AppLayout", () => {
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
});
test("skips FormbricksClient if no user is present", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const element = await AppLayout({
children: <div data-testid="child-content">Hello from children</div>,
});
render(element);
expect(screen.queryByTestId("formbricks-client")).not.toBeInTheDocument();
});
});

View File

@@ -1,13 +1,5 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
IS_POSTHOG_CONFIGURED,
POSTHOG_API_HOST,
POSTHOG_API_KEY,
} from "@/lib/constants";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -38,15 +30,6 @@ const AppLayout = async ({ children }) => {
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
{user ? (
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}

View File

@@ -2,8 +2,9 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { updateResponse } from "@/lib/response/service";
import { getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -48,10 +49,9 @@ export const PUT = async (
);
}
// update response
let response;
try {
response = await updateResponse(responseId, inputValidation.data);
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
@@ -66,27 +66,64 @@ export const PUT = async (
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(response.data, survey.questions)) {
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response,
response: updatedResponse,
});
if (response.finished) {
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response,
response: updatedResponse,
});
}
return responses.successResponse({}, true);

View File

@@ -9,6 +9,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn().mockImplementation((value, language) => {
return typeof value === "string" ? value : value[language] || value["default"] || "";
}),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({
verifyRecaptchaToken: vi.fn(),
}));

View File

@@ -4,6 +4,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
@@ -80,6 +81,23 @@ export const POST = async (request: Request, context: Context): Promise<Response
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
if (surveyCheckResult) return surveyCheckResult;
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: survey.questions,
responseLanguage: responseInputData.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
let response: TResponse;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -1,11 +0,0 @@
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import formbricks from "@formbricks/js";
export const formbricksLogout = async () => {
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
localStorage.clear();
if (loggedInWith) {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, loggedInWith);
}
return await formbricks.logout();
};

View File

@@ -3,9 +3,12 @@ import { TUserLocale } from "@formbricks/types/user";
import { env } from "./env";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const FORMBRICKS_API_HOST = env.FORMBRICKS_API_HOST;
export const FORMBRICKS_ENVIRONMENT_ID = env.FORMBRICKS_ENVIRONMENT_ID;
export const IS_FORMBRICKS_ENABLED = !!(env.FORMBRICKS_API_HOST && env.FORMBRICKS_ENVIRONMENT_ID);
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
export const WEBAPP_URL =
@@ -30,13 +33,11 @@ export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? true : false;
export const GITHUB_OAUTH_ENABLED = env.GITHUB_ID && env.GITHUB_SECRET ? true : false;
export const AZURE_OAUTH_ENABLED =
env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET && env.AZUREAD_TENANT_ID ? true : false;
export const OIDC_OAUTH_ENABLED =
env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER ? true : false;
export const SAML_OAUTH_ENABLED = env.SAML_DATABASE_URL ? true : false;
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
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);
export const SAML_OAUTH_ENABLED = !!env.SAML_DATABASE_URL;
export const SAML_XML_DIR = "./saml-connection";
export const GITHUB_ID = env.GITHUB_ID;
@@ -60,7 +61,7 @@ export const SAML_PRODUCT = "formbricks";
export const SAML_AUDIENCE = "https://saml.formbricks.com";
export const SAML_PATH = "/api/auth/saml/callback";
export const SIGNUP_ENABLED = env.SIGNUP_DISABLED !== "1";
export const SIGNUP_ENABLED = IS_FORMBRICKS_CLOUD || IS_DEVELOPMENT || E2E_TESTING;
export const EMAIL_AUTH_ENABLED = env.EMAIL_AUTH_DISABLED !== "1";
export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
@@ -96,9 +97,11 @@ export const RESPONSES_PER_PAGE = 25;
export const TEXT_RESPONSES_PER_PAGE = 5;
export const INSIGHTS_PER_PAGE = 10;
export const DOCUMENTS_PER_PAGE = 10;
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
export const MAX_OTHER_OPTION_LENGTH = 250;
export const DEFAULT_ORGANIZATION_ID = env.DEFAULT_ORGANIZATION_ID;
export const DEFAULT_ORGANIZATION_ROLE = env.DEFAULT_ORGANIZATION_ROLE;
export const SKIP_INVITE_FOR_SSO = env.AUTH_SKIP_INVITE_FOR_SSO === "1";
export const DEFAULT_TEAM_ID = env.AUTH_DEFAULT_TEAM_ID;
export const SLACK_MESSAGE_LIMIT = 2995;
export const GOOGLE_SHEET_MESSAGE_LIMIT = 49995;
@@ -112,7 +115,7 @@ export const S3_REGION = env.S3_REGION;
export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL;
export const S3_BUCKET_NAME = env.S3_BUCKET_NAME;
export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1";
export const UPLOADS_DIR = env.UPLOADS_DIR || "./uploads";
export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads";
export const MAX_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
@@ -196,7 +199,6 @@ export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
};
export const DEBUG = env.DEBUG === "1";
export const E2E_TESTING = env.E2E_TESTING === "1";
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
@@ -216,7 +218,7 @@ export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
export const STRIPE_API_VERSION = "2024-06-20";
// Maximum number of attribute classes allowed:
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150 as const;
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"];
@@ -277,10 +279,6 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";

View File

@@ -17,19 +17,13 @@ export const env = createEnv({
DATABASE_URL: z.string().url(),
DEBUG: z.enum(["1", "0"]).optional(),
DOCKER_CRON_ENABLED: z.enum(["1", "0"]).optional(),
DEFAULT_ORGANIZATION_ID: z.string().optional(),
DEFAULT_ORGANIZATION_ROLE: z.enum(["owner", "manager", "member", "billing"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
FORMBRICKS_API_HOST: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -81,7 +75,6 @@ export const env = createEnv({
S3_FORCE_PATH_STYLE: z.enum(["1", "0"]).optional(),
SAML_DATABASE_URL: z.string().optional(),
SENTRY_DSN: z.string().optional(),
SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SMTP_HOST: z.string().min(1).optional(),
@@ -131,16 +124,14 @@ export const env = createEnv({
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DEBUG: process.env.DEBUG,
DEFAULT_ORGANIZATION_ID: process.env.DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE: process.env.DEFAULT_ORGANIZATION_ROLE,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
DOCKER_CRON_ENABLED: process.env.DOCKER_CRON_ENABLED,
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
FORMBRICKS_API_HOST: process.env.FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID: process.env.FORMBRICKS_ENVIRONMENT_ID,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
@@ -184,7 +175,6 @@ export const env = createEnv({
S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL,
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
SAML_DATABASE_URL: process.env.SAML_DATABASE_URL,
SIGNUP_DISABLED: process.env.SIGNUP_DISABLED,
SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID,
SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET,
SMTP_HOST: process.env.SMTP_HOST,

View File

@@ -63,18 +63,35 @@ export const createMembership = async (
},
});
if (existingMembership) {
if (existingMembership && existingMembership.role === data.role) {
return existingMembership;
}
const membership = await prisma.membership.create({
data: {
userId,
organizationId,
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
let membership: TMembership;
if (!existingMembership) {
membership = await prisma.membership.create({
data: {
userId,
organizationId,
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
} else {
membership = await prisma.membership.update({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
data: {
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
}
organizationCache.revalidate({
userId,
});

View File

@@ -428,7 +428,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
});
segmentCache.revalidate({ id: updatedSegment.id, environmentId: updatedSegment.environmentId });
updatedSegment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
updatedSegment.surveys.forEach((survey) => surveyCache.revalidate({ id: survey.id }));
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
@@ -865,7 +865,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
});
segmentCache.revalidate({ id: currentSurveySegment.id });
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
segment.surveys.forEach((survey) => surveyCache.revalidate({ id: survey.id }));
surveyCache.revalidate({ environmentId: segment.environmentId });
}

View File

@@ -1,4 +1,5 @@
export const isValidEmail = (email): boolean => {
const regex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
export const isValidEmail = (email: string): boolean => {
// This regex comes from zod
const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i;
return regex.test(email);
};

View File

@@ -26,7 +26,6 @@ describe("DeleteAccountModal", () => {
const mockOrgs: TOrganization[] = [{ name: "Org1" }, { name: "Org2" }] as TOrganization[];
const mockSetOpen = vi.fn();
const mockLogout = vi.fn();
afterEach(() => {
cleanup();
@@ -40,7 +39,6 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={mockOrgs}
formbricksLogout={mockLogout}
/>
);
@@ -56,7 +54,6 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
@@ -78,7 +75,6 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
@@ -90,7 +86,6 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
@@ -114,7 +109,6 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={true}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
@@ -126,7 +120,6 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ redirect: true });
expect(window.location.replace).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
@@ -143,7 +136,6 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);

View File

@@ -16,7 +16,6 @@ interface DeleteAccountModalProps {
user: TUser;
isFormbricksCloud: boolean;
organizationsWithSingleOwner: TOrganization[];
formbricksLogout: () => Promise<void>;
}
export const DeleteAccountModal = ({
@@ -24,7 +23,6 @@ export const DeleteAccountModal = ({
open,
user,
isFormbricksCloud,
formbricksLogout,
organizationsWithSingleOwner,
}: DeleteAccountModalProps) => {
const { t } = useTranslate();
@@ -38,7 +36,6 @@ export const DeleteAccountModal = ({
try {
setDeleting(true);
await deleteUserAction();
await formbricksLogout();
// redirect to account deletion survey in Formbricks Cloud
if (isFormbricksCloud) {
await signOut({ redirect: true });

View File

@@ -0,0 +1,77 @@
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { TResponseData } from "@formbricks/types/responses";
import {
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
/**
* Helper function to check if a string value is a valid "other" option
* @returns BadRequestResponse if the value exceeds the limit, undefined otherwise
*/
export const validateOtherOptionLength = (
value: string,
choices: TSurveyQuestionChoice[],
questionId: string,
language?: string
): string | undefined => {
// Check if this is an "other" option (not in predefined choices)
const matchingChoice = choices.find(
(choice) => getLocalizedValue(choice.label, language ?? "default") === value
);
// If this is an "other" option with value that's too long, reject the response
if (!matchingChoice && value.length > MAX_OTHER_OPTION_LENGTH) {
return questionId;
}
};
export const validateOtherOptionLengthForMultipleChoice = ({
responseData,
surveyQuestions,
responseLanguage,
}: {
responseData: TResponseData;
surveyQuestions: TSurveyQuestion[];
responseLanguage?: string;
}): string | undefined => {
for (const [questionId, answer] of Object.entries(responseData)) {
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue;
const isMultiChoice =
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle;
if (!isMultiChoice) continue;
const error = validateAnswer(answer, question.choices, questionId, responseLanguage);
if (error) return error;
}
return undefined;
};
function validateAnswer(
answer: unknown,
choices: TSurveyQuestionChoice[],
questionId: string,
language?: string
): string | undefined {
if (typeof answer === "string") {
return validateOtherOptionLength(answer, choices, questionId, language);
}
if (Array.isArray(answer)) {
for (const item of answer) {
if (typeof item === "string") {
const result = validateOtherOptionLength(item, choices, questionId, language);
if (result) return result;
}
}
}
return undefined;
}

View File

@@ -0,0 +1,150 @@
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
import { describe, expect, test, vi } from "vitest";
import {
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { validateOtherOptionLength, validateOtherOptionLengthForMultipleChoice } from "../question";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn().mockImplementation((value, language) => {
return typeof value === "string" ? value : value[language] || value["default"] || "";
}),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({
verifyRecaptchaToken: vi.fn(),
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })),
notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })),
},
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({
getOrganizationBillingByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const mockChoices: TSurveyQuestionChoice[] = [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
];
const surveyQuestions = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: mockChoices,
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
choices: mockChoices,
},
] as unknown as TSurveyQuestion[];
describe("validateOtherOptionLength", () => {
const mockChoices: TSurveyQuestionChoice[] = [
{ id: "1", label: { default: "Option 1", fr: "Option one" } },
{ id: "2", label: { default: "Option 2", fr: "Option two" } },
{ id: "3", label: { default: "Option 3", fr: "Option Trois" } },
];
test("returns undefined when value matches a choice", () => {
const result = validateOtherOptionLength("Option 1", mockChoices, "q1");
expect(result).toBeUndefined();
});
test("returns undefined when other option is within length limit", () => {
const shortValue = "A".repeat(MAX_OTHER_OPTION_LENGTH);
const result = validateOtherOptionLength(shortValue, mockChoices, "q1");
expect(result).toBeUndefined();
});
test("uses default language when no language is provided", () => {
const result = validateOtherOptionLength("Option 3", mockChoices, "q1");
expect(result).toBeUndefined();
});
test("handles localized choice labels", () => {
const result = validateOtherOptionLength("Option Trois", mockChoices, "q1", "fr");
expect(result).toBeUndefined();
});
test("returns bad request response when other option exceeds length limit", () => {
const longValue = "A".repeat(MAX_OTHER_OPTION_LENGTH + 1);
const result = validateOtherOptionLength(longValue, mockChoices, "q1");
expect(result).toBeTruthy();
});
});
describe("validateOtherOptionLengthForMultipleChoice", () => {
test("returns undefined for single choice that matches a valid option", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q1: "Option 1" },
surveyQuestions,
});
expect(result).toBeUndefined();
});
test("returns undefined for multi-select with all valid options", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q2: ["Option 1", "Option 2"] },
surveyQuestions,
});
expect(result).toBeUndefined();
});
test("returns questionId for single choice with long 'other' option", () => {
const longText = "X".repeat(MAX_OTHER_OPTION_LENGTH + 1);
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q1: longText },
surveyQuestions,
});
expect(result).toBe("q1");
});
test("returns questionId for multi-select with one long 'other' option", () => {
const longText = "Y".repeat(MAX_OTHER_OPTION_LENGTH + 1);
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q2: [longText] },
surveyQuestions,
});
expect(result).toBe("q2");
});
test("ignores non-matching or unrelated question IDs", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { unrelated: "Other: something" },
surveyQuestions,
});
expect(result).toBeUndefined();
});
test("returns undefined if answer is not string or array", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q1: 123 as any },
surveyQuestions,
});
expect(result).toBeUndefined();
});
});

View File

@@ -1,5 +1,6 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
@@ -136,6 +137,28 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
});
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: body.data,
surveyQuestions: questionsResponse.data.questions,
responseLanguage: body.language ?? undefined,
});
if (otherResponseInvalidQuestionId) {
return handleApiError(request, {
type: "bad_request",
details: [
{
field: "response",
issue: `Response for question ${otherResponseInvalidQuestionId} exceeds character limit`,
meta: {
questionId: otherResponseInvalidQuestionId,
},
},
],
});
}
const response = await updateResponse(params.responseId, body);
if (!response.ok) {

View File

@@ -1,5 +1,6 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
@@ -90,6 +91,28 @@ export const POST = async (request: Request) =>
});
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: body.data,
surveyQuestions: surveyQuestions.data.questions,
responseLanguage: body.language ?? undefined,
});
if (otherResponseInvalidQuestionId) {
return handleApiError(request, {
type: "bad_request",
details: [
{
field: "response",
issue: `Response for question ${otherResponseInvalidQuestionId} exceeds character limit`,
meta: {
questionId: otherResponseInvalidQuestionId,
},
},
],
});
}
const createResponseResult = await createResponse(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error);

View File

@@ -177,6 +177,17 @@ export const authOptions: NextAuthOptions = {
// Conditionally add enterprise SSO providers
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
cookies: {
sessionToken: {
name: "next-auth.session-token",
options: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
},
},
session: {
maxAge: 3600,
},

View File

@@ -4,7 +4,7 @@ import { hashPassword } from "@/lib/auth";
import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@/lib/constants";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { createOrganization } from "@/lib/organization/service";
import { actionClient } from "@/lib/utils/action-client";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
@@ -14,7 +14,6 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
import { z } from "zod";
import { UnknownError } from "@formbricks/types/errors";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
const ZCreateUserAction = z.object({
@@ -23,8 +22,6 @@ const ZCreateUserAction = z.object({
password: ZUserPassword,
inviteToken: z.string().optional(),
userLocale: ZUserLocale.optional(),
defaultOrganizationId: z.string().optional(),
defaultOrganizationRole: ZOrganizationRole.optional(),
emailVerificationDisabled: z.boolean().optional(),
turnstileToken: z
.string()
@@ -92,42 +89,21 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
await deleteInvite(invite.id);
}
// Handle organization assignment
else {
let organizationId: string | undefined;
let role: TOrganizationRole = "owner";
if (parsedInput.defaultOrganizationId) {
// Use existing or create organization with specific ID
let organization = await getOrganization(parsedInput.defaultOrganizationId);
if (!organization) {
organization = await createOrganization({
id: parsedInput.defaultOrganizationId,
name: `${user.name}'s Organization`,
});
} else {
role = parsedInput.defaultOrganizationRole || "owner";
}
organizationId = organization.id;
} else {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isMultiOrgEnabled) {
// Create new organization
const organization = await createOrganization({ name: `${user.name}'s Organization` });
organizationId = organization.id;
}
}
if (organizationId) {
await createMembership(organizationId, user.id, { role, accepted: true });
} else {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isMultiOrgEnabled) {
const organization = await createOrganization({ name: `${user.name}'s Organization` });
await createMembership(organization.id, user.id, {
role: "owner",
accepted: true,
});
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
alert: { ...user.notificationSettings?.alert },
weeklySummary: { ...user.notificationSettings?.weeklySummary },
unsubscribedOrganizationIds: Array.from(
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organizationId])
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
),
},
});

View File

@@ -119,8 +119,6 @@ const defaultProps = {
isTurnstileConfigured: false,
samlTenant: "",
samlProduct: "",
defaultOrganizationId: "org1",
defaultOrganizationRole: "member",
turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false
} as const;
@@ -179,8 +177,6 @@ describe("SignupForm", () => {
userLocale: defaultProps.userLocale,
inviteToken: "",
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
defaultOrganizationId: defaultProps.defaultOrganizationId,
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
turnstileToken: undefined,
});
});
@@ -233,8 +229,6 @@ describe("SignupForm", () => {
userLocale: props.userLocale,
inviteToken: "",
emailVerificationDisabled: true,
defaultOrganizationId: props.defaultOrganizationId,
defaultOrganizationRole: props.defaultOrganizationRole,
turnstileToken: "test-turnstile-token",
});
});
@@ -286,8 +280,6 @@ describe("SignupForm", () => {
userLocale: props.userLocale,
inviteToken: "",
emailVerificationDisabled: true,
defaultOrganizationId: props.defaultOrganizationId,
defaultOrganizationRole: props.defaultOrganizationRole,
turnstileToken: "test-turnstile-token",
});
});
@@ -362,8 +354,6 @@ describe("SignupForm", () => {
userLocale: defaultProps.userLocale,
inviteToken: "token123",
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
defaultOrganizationId: defaultProps.defaultOrganizationId,
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
turnstileToken: undefined,
});
});

View File

@@ -19,7 +19,6 @@ import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Turnstile, { useTurnstile } from "react-turnstile";
import { z } from "zod";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { createEmailTokenAction } from "../../../auth/actions";
import { PasswordChecks } from "./password-checks";
@@ -45,8 +44,6 @@ interface SignupFormProps {
userLocale: TUserLocale;
emailFromSearchParams?: string;
emailVerificationDisabled: boolean;
defaultOrganizationId?: string;
defaultOrganizationRole?: TOrganizationRole;
isSsoEnabled: boolean;
samlSsoEnabled: boolean;
isTurnstileConfigured: boolean;
@@ -68,8 +65,6 @@ export const SignupForm = ({
userLocale,
emailFromSearchParams,
emailVerificationDisabled,
defaultOrganizationId,
defaultOrganizationRole,
isSsoEnabled,
samlSsoEnabled,
isTurnstileConfigured,
@@ -116,8 +111,6 @@ export const SignupForm = ({
userLocale,
inviteToken: inviteToken || "",
emailVerificationDisabled,
defaultOrganizationId,
defaultOrganizationRole,
turnstileToken,
});

View File

@@ -0,0 +1,101 @@
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { OrganizationRole, Team, TeamUserRole } from "@prisma/client";
/**
* Common constants and IDs used across tests
*/
export const MOCK_DATE = new Date("2023-01-01T00:00:00.000Z");
export const MOCK_IDS = {
// User IDs
userId: "test-user-id",
// Team IDs
teamId: "test-team-id",
defaultTeamId: "team-123",
// Organization IDs
organizationId: "test-org-id",
defaultOrganizationId: "org-123",
// Project IDs
projectId: "test-project-id",
};
/**
* Mock team data structures
*/
export const MOCK_TEAM: {
id: string;
organizationId: string;
projectTeams: { projectId: string }[];
} = {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
projectTeams: [
{
projectId: MOCK_IDS.projectId,
},
],
};
export const MOCK_DEFAULT_TEAM: Team = {
id: MOCK_IDS.defaultTeamId,
organizationId: MOCK_IDS.defaultOrganizationId,
name: "Default Team",
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock membership data
*/
export const MOCK_TEAM_USER = {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
export const MOCK_DEFAULT_TEAM_USER = {
teamId: MOCK_IDS.defaultTeamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock invitation data
*/
export const MOCK_INVITE: CreateMembershipInvite = {
organizationId: MOCK_IDS.organizationId,
role: "owner" as OrganizationRole,
teamIds: [MOCK_IDS.teamId],
};
export const MOCK_ORGANIZATION_MEMBERSHIP = {
userId: MOCK_IDS.userId,
role: "owner" as OrganizationRole,
organizationId: MOCK_IDS.defaultOrganizationId,
accepted: true,
};
/**
* Factory functions for creating test data with custom overrides
*/
export const createMockTeam = (overrides = {}) => ({
...MOCK_TEAM,
...overrides,
});
export const createMockTeamUser = (overrides = {}) => ({
...MOCK_TEAM_USER,
...overrides,
});
export const createMockInvite = (overrides = {}) => ({
...MOCK_INVITE,
...overrides,
});

View File

@@ -0,0 +1,153 @@
import { MOCK_IDS, MOCK_INVITE, MOCK_TEAM, MOCK_TEAM_USER } from "./__mocks__/team-mocks";
import { teamCache } from "@/lib/cache/team";
import { projectCache } from "@/lib/project/cache";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { createTeamMembership } from "../team";
// Setup all mocks
const setupMocks = () => {
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findUnique: vi.fn(),
},
teamUser: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/constants", () => ({
DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123",
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn().mockReturnValue("tag-id"),
byOrganizationId: vi.fn().mockReturnValue("tag-org-id"),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock reactCache to control the getDefaultTeam function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn().mockImplementation((fn) => fn),
};
});
};
// Set up mocks
setupMocks();
describe("Team Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin",
},
});
expect(projectCache.revalidate).toHaveBeenCalledWith({ id: MOCK_IDS.projectId });
expect(teamCache.revalidate).toHaveBeenCalledWith({ id: MOCK_IDS.teamId });
expect(teamCache.revalidate).toHaveBeenCalledWith({
userId: MOCK_IDS.userId,
organizationId: MOCK_IDS.organizationId,
});
expect(projectCache.revalidate).toHaveBeenCalledWith({
userId: MOCK_IDS.userId,
organizationId: MOCK_IDS.organizationId,
});
});
});
describe("when user is not an admin", () => {
test("creates a team membership with contributor role", async () => {
const nonAdminInvite: CreateMembershipInvite = {
...MOCK_INVITE,
role: "member" as OrganizationRole,
};
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
});
await createTeamMembership(nonAdminInvite, MOCK_IDS.userId);
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "contributor",
},
});
});
});
describe("error handling", () => {
test("throws error when database operation fails", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
});
});
});

View File

@@ -1,14 +1,18 @@
import "server-only";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { getAccessFlags } from "@/lib/membership/utils";
import { projectCache } from "@/lib/project/cache";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise<void> => {
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
const { isOwner, isManager } = getAccessFlags(userMembershipRole);
@@ -18,18 +22,7 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
const isOwnerOrManager = isOwner || isManager;
try {
for (const teamId of teamIds) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
const team = await getTeamProjectIds(teamId, invite.organizationId);
if (team) {
await prisma.teamUser.create({
@@ -46,7 +39,7 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
}
for (const projectId of validProjectIds) {
teamCache.revalidate({ id: projectId });
projectCache.revalidate({ id: projectId });
}
for (const teamId of validTeamIds) {
@@ -56,6 +49,7 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
teamCache.revalidate({ userId, organizationId: invite.organizationId });
projectCache.revalidate({ userId, organizationId: invite.organizationId });
} catch (error) {
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -63,3 +57,34 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
throw error;
}
};
export const getTeamProjectIds = reactCache(
async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> =>
cache(
async () => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
}
return team;
},
[`getTeamProjectIds-${teamId}-${organizationId}`],
{
tags: [teamCache.tag.byId(teamId), teamCache.tag.byOrganizationId(organizationId)],
}
)()
);

View File

@@ -1,7 +1,5 @@
import {
AZURE_OAUTH_ENABLED,
DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE,
EMAIL_AUTH_ENABLED,
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
@@ -77,8 +75,6 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
oidcDisplayName={OIDC_DISPLAY_NAME}
userLocale={locale}
emailFromSearchParams={emailFromSearchParams}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSsoEnabled={isSsoEnabled}
samlSsoEnabled={samlSsoEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}

View File

@@ -196,8 +196,12 @@ export const UploadContactsCSVButton = ({
}
if (result?.validationErrors) {
if (result.validationErrors.csvData?._errors?.[0]) {
setErrror(result.validationErrors.csvData._errors?.[0]);
const csvDataErrors = Array.isArray(result.validationErrors.csvData)
? result.validationErrors.csvData[0]?._errors?.[0]
: result.validationErrors.csvData?._errors?.[0];
if (csvDataErrors) {
setErrror(csvDataErrors);
} else {
setErrror("An error occurred while uploading the contacts. Please try again later.");
}
@@ -293,7 +297,7 @@ export const UploadContactsCSVButton = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={() => {
resetState(true);
@@ -339,7 +343,7 @@ export const UploadContactsCSVButton = ({
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>

View File

@@ -0,0 +1,71 @@
import { Organization, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getFirstOrganization } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("react", () => ({
cache: (fn: any) => fn,
}));
describe("getFirstOrganization", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the first organization when found", async () => {
const org: Organization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
whitelabel: true,
updatedAt: new Date(),
billing: {
plan: "free",
period: "monthly",
periodStart: new Date(),
stripeCustomerId: "cus_123",
limits: {
monthly: {
miu: 100,
responses: 1000,
},
projects: 3,
},
},
isAIEnabled: false,
};
vi.mocked(prisma.organization.findFirst).mockResolvedValue(org);
const result = await getFirstOrganization();
expect(result).toEqual(org);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({});
});
test("returns null if no organization is found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
const result = await getFirstOrganization();
expect(result).toBeNull();
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
await expect(getFirstOrganization()).rejects.toThrow(DatabaseError);
});
test("throws unknown error if not PrismaClientKnownRequestError", async () => {
const error = new Error("unexpected");
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
await expect(getFirstOrganization()).rejects.toThrow("unexpected");
});
});

View File

@@ -0,0 +1,27 @@
import { cache } from "@/lib/cache";
import { Organization, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export const getFirstOrganization = reactCache(
async (): Promise<Organization | null> =>
cache(
async () => {
try {
const organization = await prisma.organization.findFirst({});
return organization;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getFirstOrganization`],
{
tags: [],
}
)()
);

View File

@@ -1,8 +1,8 @@
import { createAccount } from "@/lib/account/service";
import { DEFAULT_ORGANIZATION_ID, DEFAULT_ORGANIZATION_ROLE } from "@/lib/constants";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
@@ -12,9 +12,12 @@ import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import type { IdentityProvider } from "@prisma/client";
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import type { IdentityProvider, Organization } from "@prisma/client";
import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -120,13 +123,14 @@ export const handleSsoCallback = async ({
// Get multi-org license status
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
// Reject if no callback URL and no default org in self-hosted environment
if (!callbackUrl && !DEFAULT_ORGANIZATION_ID && !isMultiOrgEnabled) {
return false;
}
const isFirstUser = await getIsFreshInstance();
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
return false;
}
// Additional security checks for self-hosted instances without default org
if (!DEFAULT_ORGANIZATION_ID && !isMultiOrgEnabled) {
try {
// Parse and validate the callback URL
const isValidCallbackUrl = new URL(callbackUrl);
@@ -157,6 +161,23 @@ export const handleSsoCallback = async ({
}
}
let organization: Organization | null = null;
if (!isFirstUser && !isMultiOrgEnabled) {
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
} else {
organization = await getFirstOrganization();
}
if (!organization) {
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) return false;
}
const userProfile = await createUser({
name:
userName ||
@@ -174,26 +195,20 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: user.id, email: user.email });
if (isMultiOrgEnabled) return true;
// Default organization assignment if env variable is set
if (DEFAULT_ORGANIZATION_ID && DEFAULT_ORGANIZATION_ID.length > 0) {
// check if organization exists
let organization = await getOrganization(DEFAULT_ORGANIZATION_ID);
let isNewOrganization = false;
if (!organization) {
// create organization with id from env
organization = await createOrganization({
id: DEFAULT_ORGANIZATION_ID,
name: userProfile.name + "'s Organization",
});
isNewOrganization = true;
}
const role = isNewOrganization ? "owner" : DEFAULT_ORGANIZATION_ROLE || "manager";
await createMembership(organization.id, userProfile.id, { role: role, accepted: true });
if (organization) {
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
await createDefaultTeamMembership(userProfile.id);
}
const updatedNotificationSettings: TUserNotificationSettings = {
...userProfile.notificationSettings,
alert: {

View File

@@ -0,0 +1,113 @@
import "server-only";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { DEFAULT_TEAM_ID } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { Organization, Team } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
export const getOrganizationByTeamId = reactCache(
async (teamId: string): Promise<Organization | null> =>
cache(
async () => {
validateInputs([teamId, z.string().cuid2()]);
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
organization: true,
},
});
if (!team) {
return null;
}
return team.organization;
} catch (error) {
logger.error(error, `Error getting organization by team id ${teamId}`);
return null;
}
},
[`getOrganizationByTeamId-${teamId}`],
{
tags: [teamCache.tag.byId(teamId)],
}
)()
);
const getTeam = reactCache(
async (teamId: string): Promise<Team> =>
cache(
async () => {
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
if (!team) {
throw new Error("Team not found");
}
return team;
} catch (error) {
logger.error(error, `Team not found ${teamId}`);
throw error;
}
},
[`getTeam-${teamId}`],
{
tags: [teamCache.tag.byId(teamId)],
}
)()
);
export const createDefaultTeamMembership = async (userId: string) => {
try {
const defaultTeamId = DEFAULT_TEAM_ID;
if (!defaultTeamId) {
logger.error("Default team ID not found");
return;
}
const defaultTeam = await getTeam(defaultTeamId);
if (!defaultTeam) {
logger.error("Default team not found");
return;
}
const organizationMembership = await getMembershipByUserIdOrganizationId(
userId,
defaultTeam.organizationId
);
if (!organizationMembership) {
logger.error("Organization membership not found");
return;
}
const membershipRole = organizationMembership.role;
await createTeamMembership(
{
organizationId: defaultTeam.organizationId,
role: membershipRole,
teamIds: [defaultTeamId],
},
userId
);
} catch (error) {
logger.error("Error creating default team membership", error);
}
};

View File

@@ -0,0 +1,101 @@
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { OrganizationRole, Team, TeamUserRole } from "@prisma/client";
/**
* Common constants and IDs used across tests
*/
export const MOCK_DATE = new Date("2023-01-01T00:00:00.000Z");
export const MOCK_IDS = {
// User IDs
userId: "test-user-id",
// Team IDs
teamId: "test-team-id",
defaultTeamId: "team-123",
// Organization IDs
organizationId: "test-org-id",
defaultOrganizationId: "org-123",
// Project IDs
projectId: "test-project-id",
};
/**
* Mock team data structures
*/
export const MOCK_TEAM: {
id: string;
organizationId: string;
projectTeams: { projectId: string }[];
} = {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
projectTeams: [
{
projectId: MOCK_IDS.projectId,
},
],
};
export const MOCK_DEFAULT_TEAM: Team = {
id: MOCK_IDS.defaultTeamId,
organizationId: MOCK_IDS.defaultOrganizationId,
name: "Default Team",
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock membership data
*/
export const MOCK_TEAM_USER = {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
export const MOCK_DEFAULT_TEAM_USER = {
teamId: MOCK_IDS.defaultTeamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock invitation data
*/
export const MOCK_INVITE: CreateMembershipInvite = {
organizationId: MOCK_IDS.organizationId,
role: "owner" as OrganizationRole,
teamIds: [MOCK_IDS.teamId],
};
export const MOCK_ORGANIZATION_MEMBERSHIP = {
userId: MOCK_IDS.userId,
role: "owner" as OrganizationRole,
organizationId: MOCK_IDS.defaultOrganizationId,
accepted: true,
};
/**
* Factory functions for creating test data with custom overrides
*/
export const createMockTeam = (overrides = {}) => ({
...MOCK_TEAM,
...overrides,
});
export const createMockTeamUser = (overrides = {}) => ({
...MOCK_TEAM_USER,
...overrides,
});
export const createMockInvite = (overrides = {}) => ({
...MOCK_INVITE,
...overrides,
});

View File

@@ -1,11 +1,16 @@
import { createAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import type { TSamlNameFields } from "@/modules/auth/types/auth";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
@@ -31,20 +36,31 @@ vi.mock("@/modules/auth/lib/user", () => ({
createUser: vi.fn(),
}));
vi.mock("@/modules/auth/signup/lib/invite", () => ({
getIsValidInviteToken: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
getisSsoEnabled: vi.fn(),
getIsMultiOrgEnabled: vi.fn().mockResolvedValue(true),
getRoleManagementPermission: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findFirst: vi.fn(),
count: vi.fn(), // Add count mock for user
},
},
}));
vi.mock("@/modules/ee/sso/lib/team", () => ({
getOrganizationByTeamId: vi.fn(),
createDefaultTeamMembership: vi.fn(),
}));
vi.mock("@/lib/account/service", () => ({
createAccount: vi.fn(),
}));
@@ -62,21 +78,35 @@ vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@formbricks/lib/jwt", () => ({
verifyInviteToken: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock environment variables
vi.mock("@/lib/constants", () => ({
SKIP_INVITE_FOR_SSO: 0,
DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123",
DEFAULT_ORGANIZATION_ROLE: "member",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
}));
describe("handleSsoCallback", () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
// Default mock implementations
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
// Mock organization-related functions
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
@@ -88,6 +118,7 @@ describe("handleSsoCallback", () => {
organizationId: mockOrganization.id,
});
vi.mocked(updateUser).mockResolvedValue({ ...mockUser, id: "user-123" });
vi.mocked(createDefaultTeamMembership).mockResolvedValue(undefined);
});
describe("Early return conditions", () => {
@@ -260,11 +291,11 @@ describe("handleSsoCallback", () => {
expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email });
});
test("should create organization and membership for new user when DEFAULT_ORGANIZATION_ID is set", async () => {
test("should return true when organization doesn't exist with DEFAULT_TEAM_ID", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganization).mockResolvedValue(null);
vi.mocked(getOrganizationByTeamId).mockResolvedValue(null);
const result = await handleSsoCallback({
user: mockUser,
@@ -273,29 +304,15 @@ describe("handleSsoCallback", () => {
});
expect(result).toBe(true);
expect(createOrganization).toHaveBeenCalledWith({
id: "org-123",
name: expect.stringContaining("Organization"),
});
expect(createMembership).toHaveBeenCalledWith("org-123", mockCreatedUser().id, {
role: "owner",
accepted: true,
});
expect(createAccount).toHaveBeenCalledWith({
...mockAccount,
userId: mockCreatedUser().id,
});
expect(updateUser).toHaveBeenCalledWith(mockCreatedUser().id, {
notificationSettings: expect.objectContaining({
unsubscribedOrganizationIds: ["org-123"],
}),
});
expect(getRoleManagementPermission).not.toHaveBeenCalled();
});
test("should use existing organization if it exists", async () => {
test("should return true when organization exists but role management is not enabled", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
const result = await handleSsoCallback({
user: mockUser,
@@ -304,16 +321,15 @@ describe("handleSsoCallback", () => {
});
expect(result).toBe(true);
expect(createOrganization).not.toHaveBeenCalled();
expect(createMembership).toHaveBeenCalledWith(mockOrganization.id, mockCreatedUser().id, {
role: "member",
accepted: true,
});
expect(createMembership).not.toHaveBeenCalled();
});
});
describe("OpenID Connect name handling", () => {
test("should use oidcUser.name when available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: "Direct Name",
given_name: "John",
@@ -332,16 +348,14 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "Direct Name",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should use given_name + family_name when name is not available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: "John",
@@ -360,16 +374,14 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "John Doe",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should use preferred_username when name and given_name/family_name are not available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: undefined,
@@ -389,16 +401,14 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "preferred.user",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should fallback to email username when no OIDC name fields are available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: undefined,
@@ -419,11 +429,6 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "test user",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
@@ -431,6 +436,9 @@ describe("handleSsoCallback", () => {
describe("SAML name handling", () => {
test("should use samlUser.name when available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const samlUser = {
...mockUser,
name: "Direct Name",
@@ -450,16 +458,14 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "Direct Name",
email: samlUser.email,
emailVerified: expect.any(Date),
identityProvider: "saml",
identityProviderAccountId: mockSamlAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should use firstName + lastName when name is not available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const samlUser = {
...mockUser,
name: "",
@@ -479,56 +485,31 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "John Doe",
email: samlUser.email,
emailVerified: expect.any(Date),
identityProvider: "saml",
identityProviderAccountId: mockSamlAccount.providerAccountId,
locale: "en-US",
})
);
});
});
describe("Organization handling", () => {
test("should handle invalid DEFAULT_ORGANIZATION_ID gracefully", async () => {
describe("Auto-provisioning and invite handling", () => {
test("should return false when auto-provisioning is disabled and no callback URL or multi-org", async () => {
vi.resetModules();
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganization).mockResolvedValue(null);
vi.mocked(createOrganization).mockRejectedValue(new Error("Invalid organization ID"));
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("Invalid organization ID");
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "",
});
expect(createOrganization).toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
});
test("should handle membership creation failure gracefully", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(createMembership).mockRejectedValue(new Error("Failed to create membership"));
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("Failed to create membership");
expect(createMembership).toHaveBeenCalled();
expect(result).toBe(false);
});
});
describe("Error handling", () => {
test("should handle prisma errors gracefully", async () => {
test("should handle database errors", async () => {
vi.mocked(prisma.user.findFirst).mockRejectedValue(new Error("Database error"));
await expect(
@@ -540,11 +521,10 @@ describe("handleSsoCallback", () => {
).rejects.toThrow("Database error");
});
test("should handle locale finding errors gracefully", async () => {
test("should handle locale finding errors", async () => {
vi.mocked(findMatchingLocale).mockRejectedValue(new Error("Locale error"));
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
await expect(
handleSsoCallback({

View File

@@ -0,0 +1,180 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "../team";
import {
MOCK_DEFAULT_TEAM,
MOCK_DEFAULT_TEAM_USER,
MOCK_IDS,
MOCK_ORGANIZATION_MEMBERSHIP,
} from "./__mock__/team.mock";
// Setup all mocks
const setupMocks = () => {
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findUnique: vi.fn(),
},
teamUser: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/constants", () => ({
DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123",
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn().mockReturnValue("tag-id"),
byOrganizationId: vi.fn().mockReturnValue("tag-org-id"),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((args) => args),
}));
// Mock reactCache to control the getDefaultTeam function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn().mockImplementation((fn) => fn),
};
});
};
// Set up mocks
setupMocks();
describe("Team Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createDefaultTeamMembership", () => {
describe("when all dependencies are available", () => {
test("creates the default team membership successfully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP);
vi.mocked(prisma.team.findUnique).mockResolvedValue({
projectTeams: { projectId: ["test-project-id"] },
});
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_DEFAULT_TEAM_USER);
await createDefaultTeamMembership(MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: "team-123",
},
});
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: "team-123",
userId: MOCK_IDS.userId,
role: "admin",
},
});
});
});
describe("error handling", () => {
test("handles missing default team gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
await createDefaultTeamMembership(MOCK_IDS.userId);
});
test("handles missing organization membership gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
await createDefaultTeamMembership(MOCK_IDS.userId);
});
test("handles database errors gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await createDefaultTeamMembership(MOCK_IDS.userId);
});
});
});
describe("getOrganizationByTeamId", () => {
const mockOrganization = { id: "org-1", name: "Test Org" };
beforeEach(() => {
vi.clearAllMocks();
});
test("returns organization when team is found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
organization: mockOrganization,
} as any);
const result = await getOrganizationByTeamId("team-1");
expect(result).toEqual(mockOrganization);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: { id: "team-1" },
select: { organization: true },
});
});
test("returns null when team is not found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
const result = await getOrganizationByTeamId("team-2");
expect(result).toBeNull();
});
test("returns null and logs error when prisma throws", async () => {
const error = new Error("DB error");
vi.mocked(prisma.team.findUnique).mockRejectedValueOnce(error);
const result = await getOrganizationByTeamId("team-3");
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(error, "Error getting organization by team id team-3");
});
test("calls validateInputs with correct arguments", async () => {
const mockTeamId = "team-xyz";
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ organization: mockOrganization } as any);
await getOrganizationByTeamId(mockTeamId);
expect(validateInputs).toHaveBeenCalledWith([mockTeamId, expect.anything()]);
});
});
});

View File

@@ -365,7 +365,7 @@ export const AddApiKeyModal = ({
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{t(getOrganizationAccessKeyDisplayName(key))}</div>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}

View File

@@ -178,7 +178,7 @@ export const ViewPermissionModal = ({
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{t(getOrganizationAccessKeyDisplayName(key))}</div>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}

View File

@@ -1,3 +1,4 @@
import { TFnType } from "@tolgee/react";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
// Permission level required for different HTTP methods
@@ -41,11 +42,11 @@ export const hasPermission = (
}
};
export const getOrganizationAccessKeyDisplayName = (key: string) => {
export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) => {
switch (key) {
case "accessControl":
return "environments.project.api_keys.access_control";
return t("environments.project.api_keys.access_control");
default:
return key;
return t(key);
}
};

View File

@@ -1,7 +1,5 @@
import {
AZURE_OAUTH_ENABLED,
DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE,
EMAIL_AUTH_ENABLED,
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
@@ -53,8 +51,6 @@ export const SignupPage = async () => {
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
oidcDisplayName={OIDC_DISPLAY_NAME}
userLocale={locale}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSsoEnabled={isSsoEnabled}
samlSsoEnabled={samlSsoEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}

View File

@@ -1,6 +1,5 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -29,7 +28,6 @@ export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFrom
setOpen={setIsModalOpen}
user={user}
isFormbricksCloud={isFormbricksCloud}
formbricksLogout={formbricksLogout}
organizationsWithSingleOwner={[]}
/>
<Button

View File

@@ -9,8 +9,6 @@ import { QuestionFormInput } from "./index";
// Mock all the modules that might cause server-side environment variable access issues
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
FORMBRICKS_API_HOST: "http://localhost:3000",
FORMBRICKS_ENVIRONMENT_ID: "test-env-id",
ENCRYPTION_KEY: "test-encryption-key",
WEBAPP_URL: "http://localhost:3000",
DEFAULT_BRAND_COLOR: "#64748b",
@@ -45,8 +43,6 @@ vi.mock("@/lib/constants", () => ({
vi.mock("@/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
FORMBRICKS_API_HOST: "http://localhost:3000",
FORMBRICKS_ENVIRONMENT_ID: "test-env-id",
ENCRYPTION_KEY: "test-encryption-key",
NODE_ENV: "test",
ENTERPRISE_LICENSE_KEY: "test-license-key",

View File

@@ -16,8 +16,6 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
FORMBRICKS_API_HOST: "http://localhost:3000",
FORMBRICKS_ENVIRONMENT_ID: "test-env-id",
}));
// Mock the createActionClassAction function

View File

@@ -1,3 +1,4 @@
import { isValidEmail } from "@/lib/utils/email";
import { cn } from "@/modules/ui/lib/utils";
import React, { useState } from "react";
@@ -15,15 +16,12 @@ const FollowUpActionMultiEmailInput = ({
const [inputValue, setInputValue] = useState("");
const [error, setError] = useState("");
// Email validation regex
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const handleAddEmail = () => {
const email = inputValue.trim();
if (!email) return;
if (!emailRegex.test(email)) {
if (!isValidEmail(email)) {
setError("Please enter a valid email address");
return;
}
@@ -77,7 +75,7 @@ const FollowUpActionMultiEmailInput = ({
<span className="text-slate-900">{email}</span>
<button
onClick={() => removeEmail(index)}
className="px-1 text-lg font-medium leading-none text-slate-500">
className="px-1 text-lg leading-none font-medium text-slate-500">
×
</button>
</div>

View File

@@ -40,8 +40,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
const filteredData = data.projects.filter((project) => project.environments.length > 0);
try {
filteredData.map(async (project) => {
project.environments.map(async (environment) => {
filteredData.forEach(async (project) => {
project.environments.forEach(async (environment) => {
await copySurveyToOtherEnvironmentAction({
environmentId: survey.environmentId,
surveyId: survey.id,
@@ -98,11 +98,11 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
field.onChange([...field.value, environment.id]);
}
}}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
id={environment.id}
/>
<Label htmlFor={environment.id}>
<p className="text-sm font-medium capitalize text-slate-900">
<p className="text-sm font-medium text-slate-900 capitalize">
{environment.type}
</p>
</Label>
@@ -121,8 +121,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
);
})}
</div>
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pb-4 pr-4">
<div className="fixed right-0 bottom-0 left-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pr-4 pb-4">
<Button type="button" onClick={onCancel} variant="ghost">
{t("common.cancel")}
</Button>

View File

@@ -0,0 +1,188 @@
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { CopySurveyForm } from "../copy-survey-form";
// Mock dependencies
vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn().mockResolvedValue({}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock the Checkbox component to properly handle form changes
vi.mock("@/modules/ui/components/checkbox", () => ({
Checkbox: ({ id, onCheckedChange, ...props }: any) => (
<input
type="checkbox"
id={id}
data-testid={id}
name={props.name}
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
onChange={() => {
// Call onCheckedChange with true to simulate checkbox selection
onCheckedChange(true);
}}
{...props}
/>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, type, variant, ...rest }: any) => (
<button
data-testid={`button-${type || "button"}`}
onClick={onClick}
type={type || "button"}
data-variant={variant}
{...rest}>
{children}
</button>
),
}));
// Mock data
const mockSurvey = {
id: "survey-1",
name: "mockSurvey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-1",
status: "draft",
singleUse: null,
responseCount: 0,
creator: null,
} as any;
const mockProjects = [
{
id: "project-1",
name: "Project 1",
environments: [
{ id: "env-1", type: "development" },
{ id: "env-2", type: "production" },
],
},
{
id: "project-2",
name: "Project 2",
environments: [
{ id: "env-3", type: "development" },
{ id: "env-4", type: "production" },
],
},
] satisfies TUserProject[];
describe("CopySurveyForm", () => {
const mockSetOpen = vi.fn();
const mockOnCancel = vi.fn();
const user = userEvent.setup();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(copySurveyToOtherEnvironmentAction).mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
test("renders the form with correct project and environment options", () => {
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
// Check if project names are rendered
expect(screen.getByText("Project 1")).toBeInTheDocument();
expect(screen.getByText("Project 2")).toBeInTheDocument();
// Check if environment types are rendered
expect(screen.getAllByText("development").length).toBe(2);
expect(screen.getAllByText("production").length).toBe(2);
// Check if checkboxes are rendered for each environment
expect(screen.getByTestId("env-1")).toBeInTheDocument();
expect(screen.getByTestId("env-2")).toBeInTheDocument();
expect(screen.getByTestId("env-3")).toBeInTheDocument();
expect(screen.getByTestId("env-4")).toBeInTheDocument();
});
test("calls onCancel when cancel button is clicked", async () => {
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
const cancelButton = screen.getByText("common.cancel");
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
test("toggles environment selection when checkbox is clicked", async () => {
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
// Select multiple environments
await user.click(screen.getByTestId("env-2"));
await user.click(screen.getByTestId("env-3"));
// Submit the form
await user.click(screen.getByTestId("button-submit"));
// Success toast should be called because of how the component is implemented
expect(toast.success).toHaveBeenCalled();
});
test("submits form with selected environments", async () => {
render(
<CopySurveyForm
defaultProjects={mockProjects}
survey={mockSurvey}
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
// Select environments
await user.click(screen.getByTestId("env-2"));
await user.click(screen.getByTestId("env-4"));
// Submit the form
await user.click(screen.getByTestId("button-submit"));
// Success toast should be called because of how the component is implemented
expect(toast.success).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,10 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import { signOut } from "next-auth/react";
import { useEffect } from "react";
export const ClientLogout = () => {
useEffect(() => {
formbricksLogout();
signOut();
});
return null;

View File

@@ -3,9 +3,7 @@ import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin";
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
const EMAIL_MATCHER =
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
const EMAIL_MATCHER = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
const MATCHERS = [
(text: any) => {
const match = URL_MATCHER.exec(text);

View File

@@ -27,8 +27,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
FORMBRICKS_API_HOST: "test-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "test-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
}));
@@ -36,13 +34,6 @@ vi.mock("@/lib/constants", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: any) => <div data-testid="ResponseFilterProvider">{children}</div>,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
@@ -69,7 +60,6 @@ describe("EnvironmentIdBaseLayout", () => {
expect(screen.getByTestId("ResponseFilterProvider")).toBeInTheDocument();
expect(screen.getByTestId("PosthogIdentify")).toHaveTextContent("org1");
expect(screen.getByTestId("FormbricksClient")).toHaveTextContent("user1-user1@example.com");
expect(screen.getByTestId("ToasterClient")).toBeInTheDocument();
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});

View File

@@ -1,12 +1,6 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
IS_POSTHOG_CONFIGURED,
} from "@/lib/constants";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { Session } from "next-auth";
import { TOrganization } from "@formbricks/types/organizations";
@@ -38,13 +32,6 @@ export const EnvironmentIdBaseLayout = async ({
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
<ToasterClient />
{children}
</ResponseFilterProvider>

View File

@@ -18,6 +18,7 @@ const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: false,
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
outputFileTracingIncludes: {
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],

View File

@@ -17,30 +17,30 @@
"generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints"
},
"dependencies": {
"@aws-sdk/client-s3": "3.782.0",
"@aws-sdk/s3-presigned-post": "3.782.0",
"@aws-sdk/s3-request-presigner": "3.782.0",
"@boxyhq/saml-jackson": "1.45.0",
"@aws-sdk/client-s3": "3.802.0",
"@aws-sdk/s3-presigned-post": "3.802.0",
"@aws-sdk/s3-request-presigner": "3.802.0",
"@boxyhq/saml-jackson": "1.45.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formbricks/database": "workspace:*",
"@formbricks/js": "workspace:*",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/js-core": "workspace:*",
"@formbricks/logger": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/types": "workspace:*",
"@hookform/resolvers": "5.0.1",
"@intercom/messenger-js-sdk": "0.0.14",
"@json2csv/node": "7.0.6",
"@lexical/code": "0.30.0",
"@lexical/link": "0.30.0",
"@lexical/list": "0.30.0",
"@lexical/markdown": "0.30.0",
"@lexical/react": "0.30.0",
"@lexical/rich-text": "0.30.0",
"@lexical/table": "0.30.0",
"@lexical/code": "0.31.0",
"@lexical/link": "0.31.0",
"@lexical/list": "0.31.0",
"@lexical/markdown": "0.31.0",
"@lexical/react": "0.31.0",
"@lexical/rich-text": "0.31.0",
"@lexical/table": "0.31.0",
"@opentelemetry/api-logs": "0.56.0",
"@opentelemetry/exporter-prometheus": "0.57.2",
"@opentelemetry/host-metrics": "0.35.5",
@@ -50,37 +50,37 @@
"@opentelemetry/sdk-logs": "0.56.0",
"@opentelemetry/sdk-metrics": "1.30.1",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.6.0",
"@radix-ui/react-accordion": "1.2.4",
"@radix-ui/react-checkbox": "1.1.5",
"@radix-ui/react-collapsible": "1.1.4",
"@radix-ui/react-dialog": "1.1.7",
"@radix-ui/react-dropdown-menu": "2.1.7",
"@radix-ui/react-label": "2.1.3",
"@radix-ui/react-popover": "1.1.7",
"@radix-ui/react-radio-group": "1.2.4",
"@radix-ui/react-select": "2.1.7",
"@radix-ui/react-separator": "1.1.3",
"@radix-ui/react-slider": "1.2.4",
"@prisma/client": "6.7.0",
"@radix-ui/react-accordion": "1.2.9",
"@radix-ui/react-checkbox": "1.3.0",
"@radix-ui/react-collapsible": "1.1.9",
"@radix-ui/react-dialog": "1.1.12",
"@radix-ui/react-dropdown-menu": "2.1.13",
"@radix-ui/react-label": "2.1.5",
"@radix-ui/react-popover": "1.1.12",
"@radix-ui/react-radio-group": "1.3.5",
"@radix-ui/react-select": "2.2.3",
"@radix-ui/react-separator": "1.1.5",
"@radix-ui/react-slider": "1.3.3",
"@radix-ui/react-slot": "1.2.0",
"@radix-ui/react-switch": "1.1.4",
"@radix-ui/react-tabs": "1.1.4",
"@radix-ui/react-toggle": "1.1.3",
"@radix-ui/react-toggle-group": "1.1.3",
"@radix-ui/react-tooltip": "1.2.0",
"@react-email/components": "0.0.36",
"@sentry/nextjs": "9.12.0",
"@t3-oss/env-nextjs": "0.12.0",
"@radix-ui/react-switch": "1.2.3",
"@radix-ui/react-tabs": "1.1.10",
"@radix-ui/react-toggle": "1.1.7",
"@radix-ui/react-toggle-group": "1.1.8",
"@radix-ui/react-tooltip": "1.2.5",
"@react-email/components": "0.0.38",
"@sentry/nextjs": "9.15.0",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.2",
"@tanstack/react-table": "8.21.3",
"@testing-library/jest-dom": "6.6.3",
"@tolgee/cli": "2.10.2",
"@tolgee/format-icu": "6.2.4",
"@tolgee/react": "6.2.4",
"@ungap/structured-clone": "1.3.0",
"@unkey/ratelimit": "0.5.5",
"@vercel/functions": "2.0.0",
"@vercel/functions": "2.0.1",
"@vercel/og": "0.6.8",
"autoprefixer": "10.4.21",
"bcryptjs": "3.0.2",
@@ -90,40 +90,40 @@
"cmdk": "1.1.1",
"csv-parse": "5.6.0",
"date-fns": "4.1.0",
"dotenv": "16.4.7",
"dotenv": "16.5.0",
"file-loader": "6.2.0",
"framer-motion": "12.6.3",
"framer-motion": "12.9.7",
"googleapis": "148.0.0",
"heic-convert": "2.1.0",
"https-proxy-agent": "7.0.6",
"jiti": "2.4.2",
"jsonwebtoken": "9.0.2",
"lexical": "0.30.0",
"lexical": "0.31.0",
"lodash": "4.17.21",
"lru-cache": "11.1.0",
"lucide-react": "0.487.0",
"lucide-react": "0.507.0",
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
"nanoid": "5.1.5",
"next": "15.2.5",
"next": "15.3.1",
"next-auth": "4.24.11",
"next-safe-action": "7.10.5",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",
"nodemailer": "6.10.0",
"nodemailer": "7.0.2",
"otplib": "12.0.1",
"papaparse": "5.5.2",
"postcss": "8.5.3",
"posthog-js": "1.235.0",
"posthog-node": "4.11.3",
"posthog-js": "1.239.1",
"posthog-node": "4.17.1",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qr-code-styling": "1.9.1",
"qr-code-styling": "1.9.2",
"react": "19.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.5",
"react-day-picker": "9.6.7",
"react-dom": "19.1.0",
"react-hook-form": "7.55.0",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-turnstile": "1.1.4",
"react-use": "17.6.0",
@@ -136,7 +136,7 @@
"tailwindcss": "3.4.16",
"ua-parser-js": "2.0.3",
"uuid": "11.1.0",
"webpack": "5.99.5",
"webpack": "5.99.7",
"xlsx": "0.18.5",
"zod": "3.24.1",
"zod-openapi": "4.2.4"
@@ -145,11 +145,11 @@
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@neshca/cache-handler": "1.9.0",
"@testing-library/react": "16.2.0",
"@testing-library/react": "16.3.0",
"@types/bcryptjs": "2.4.6",
"@types/heic-convert": "2.1.0",
"@types/jsonwebtoken": "9.0.9",
"@types/lodash": "4.17.13",
"@types/lodash": "4.17.16",
"@types/markdown-it": "14.1.2",
"@types/mime-types": "2.1.4",
"@types/nodemailer": "6.4.17",
@@ -157,13 +157,13 @@
"@types/qrcode": "1.5.5",
"@types/testing-library__react": "10.2.0",
"@types/ungap__structured-clone": "1.2.0",
"@vitest/coverage-v8": "3.1.1",
"dotenv": "16.4.7",
"@vitest/coverage-v8": "3.1.3",
"dotenv": "16.5.0",
"ts-node": "10.9.2",
"resize-observer-polyfill": "1.5.1",
"vite": "6.2.5",
"vite": "6.3.5",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.1.1",
"vitest-mock-extended": "3.0.1"
"vitest": "3.1.3",
"vitest-mock-extended": "3.1.0"
}
}

View File

@@ -46,10 +46,10 @@ export default defineConfig({
"app/(app)/environments/**/components/PosthogIdentify.tsx",
"app/(app)/(onboarding)/organizations/**/layout.tsx",
"app/(app)/(survey-editor)/environments/**/layout.tsx",
"app/(app)/components/FormbricksClient.tsx",
"app/(auth)/layout.tsx",
"app/(app)/layout.tsx",
"app/layout.tsx",
"app/api/v2/client/**/responses/lib/utils.ts",
"app/intercom/*.tsx",
"app/sentry/*.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ConsentSummary.tsx",
@@ -80,6 +80,7 @@ export default defineConfig({
"app/(app)/environments/**/surveys/**/(analysis)/responses/components/ResponseTableCell.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
"modules/ee/sso/lib/**/*.ts",
"modules/ee/sso/lib/organization.ts",
"app/lib/**/*.ts",
"modules/ee/license-check/lib/utils.ts",
"app/api/(internal)/insights/lib/**/*.ts",
@@ -98,11 +99,13 @@ export default defineConfig({
"modules/survey/components/edit-public-survey-alert-dialog/index.tsx",
"modules/survey/list/components/survey-card.tsx",
"modules/survey/list/components/survey-dropdown-menu.tsx",
"modules/auth/signup/**/*.ts",
"modules/survey/follow-ups/components/follow-up-item.tsx",
"modules/ee/contacts/segments/*",
"modules/survey/editor/lib/utils.tsx",
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
"modules/ee/sso/components/**/*.tsx",
"modules/ee/sso/lib/team.ts",
"app/global-error.tsx",
"app/error.tsx",
"modules/survey/lib/permission.ts",
@@ -114,6 +117,7 @@ export default defineConfig({
"modules/survey/editor/components/end-screen-form.tsx",
"modules/survey/editor/components/matrix-question-form.tsx",
"lib/utils/billing.ts",
"modules/survey/list/components/copy-survey-form.tsx",
"lib/crypto.ts",
"lib/surveyLogic/utils.ts",
"lib/utils/billing.ts",
@@ -164,6 +168,7 @@ export default defineConfig({
"**/openapi.ts", // Exclude openapi configuration files
"**/openapi-document.ts", // Exclude openapi document files
"modules/**/types/**", // Exclude types
"**/stories.tsx", // Exclude story files
],
},
},

View File

@@ -78,10 +78,6 @@ x-environment: &environment
# Set the below to your Survey Domain(default is WEBAPP_URL)
# SURVEY_URL:
# Configure Formbricks usage within Formbricks.
# FORMBRICKS_API_HOST:
# FORMBRICKS_ENVIRONMENT_ID:
# The SENTRY_DSN is used for error tracking and performance monitoring with Sentry.
# SENTRY_DSN:
# It's used for authentication when uploading source maps to Sentry, to make errors more readable.
@@ -186,11 +182,11 @@ x-environment: &environment
############################################# OPTIONAL (OTHER) #############################################
# Set the below to automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# signup is disabled by default for self-hosted instances, users can only signup using an invite link, in order to allow signup from SSO(without invite), set the below to 1
# AUTH_SKIP_INVITE_FOR_SSO=1
# Set the below to automatically assign new users to a specific team, insert an existing team id
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ID:
# DEFAULT_ORGANIZATION_ROLE: owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# Set the below to 1 to disable the user management UI
# DISABLE_USER_MANAGEMENT: 0

View File

@@ -6,7 +6,7 @@ icon: "code"
#### Custom Configurations
These variables are present inside your machines docker-compose file. Restart the docker containers if you change any variables for them to take effect.
These variables are present inside your machine's docker-compose file. Restart the docker containers if you change any variables for them to take effect.
| Variable | Description | Required | Default |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -67,8 +67,10 @@ These variables are present inside your machines docker-compose file. Restart
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
| SURVEY_URL | Set this to change the domain of the survey. | optional | WEBAPP_URL
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional |
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional |
| DISABLE_USER_MANAGEMENT | Set this to hide the user management UI. | optional |
| DEFAULT_TEAM_ID | Default team ID for new users. | optional | |
| SURVEY_URL | Set this to change the domain of the survey. | optional | WEBAPP_URL |
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional |
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional |
| DISABLE_USER_MANAGEMENT | Set this to hide the user management UI. | optional |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and well try our best to work out a solution with you.

View File

@@ -26,7 +26,6 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"generate": "turbo run generate",
"lint": "turbo run lint",
"release": "turbo run build --filter=@formbricks/js... && changeset publish",
"test": "turbo run test --no-cache",
"test:coverage": "turbo run test:coverage --no-cache",
"test:e2e": "playwright test",
@@ -39,13 +38,13 @@
"devDependencies": {
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
"@formbricks/eslint-config": "workspace:*",
"@playwright/test": "1.51.1",
"@playwright/test": "1.52.0",
"eslint": "8.57.0",
"husky": "9.1.7",
"lint-staged": "15.5.0",
"lint-staged": "15.5.1",
"rimraf": "6.0.1",
"tsx": "4.19.3",
"turbo": "2.5.0"
"tsx": "4.19.4",
"turbo": "2.5.2"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
@@ -69,6 +68,6 @@
"showDetails": true
},
"dependencies": {
"@changesets/cli": "2.28.1"
"@changesets/cli": "2.29.3"
}
}

View File

@@ -7,9 +7,9 @@
"clean": "rimraf node_modules dist turbo"
},
"devDependencies": {
"@types/node": "22.14.0",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
"@types/node": "22.15.3",
"@types/react": "19.1.2",
"@types/react-dom": "19.1.3",
"typescript": "5.8.3"
}
}

View File

@@ -24,7 +24,7 @@
},
"dependencies": {
"@formbricks/logger": "workspace:*",
"@prisma/client": "6.6.0",
"@prisma/client": "6.7.0",
"@prisma/extension-accelerate": "1.3.0",
"dotenv-cli": "8.0.0",
"zod-openapi": "4.2.4"
@@ -33,7 +33,7 @@
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"prisma": "6.6.0",
"prisma": "6.7.0",
"prisma-json-types-generator": "3.2.3",
"ts-node": "10.9.2",
"zod": "3.24.1"

View File

@@ -32,9 +32,9 @@
},
"peerDependencies": {},
"devDependencies": {
"vite": "^6.2.4",
"vite": "6.3.5",
"@formbricks/config-typescript": "workspace:*",
"vitest": "3.1.1",
"vitest": "3.1.3",
"@formbricks/eslint-config": "workspace:*",
"vite-plugin-dts": "4.5.3"
}

View File

@@ -45,10 +45,10 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "3.1.1",
"@vitest/coverage-v8": "3.1.3",
"terser": "5.39.0",
"vite": "6.2.5",
"vite": "6.3.5",
"vite-plugin-dts": "4.5.3",
"vitest": "3.1.1"
"vitest": "3.1.3"
}
}

View File

@@ -184,7 +184,7 @@ export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: stri
};
export const shouldDisplayBasedOnPercentage = (displayPercentage: number): boolean => {
const randomNum = Math.floor(Math.random() * 10000) / 100;
const randomNum = Math.floor(Math.random() * 10000) / 100; // NOSONAR typescript:S2245 // Math.random() is not used in a security context
return randomNum <= displayPercentage;
};
@@ -276,7 +276,9 @@ export const evaluateNoCodeConfigClick = (
if (cssSelector) {
// Split selectors that start with a . or # including the . or #
const individualSelectors = cssSelector.split(/\s*(?=[.#])/);
const individualSelectors = cssSelector
.split(/(?=[.#])/) // split before each . or #
.map((sel) => sel.trim()); // remove leftover whitespace
for (const selector of individualSelectors) {
if (!targetElement.matches(selector)) {
return false;

View File

@@ -1,7 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/library.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

View File

@@ -1,4 +0,0 @@
node_modules
.vscode
build
dist

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2024 Formbricks GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,36 +0,0 @@
# Formbricks Browser JS Library
[![npm package](https://img.shields.io/npm/v/@formbricks/js?style=flat-square)](https://www.npmjs.com/package/@formbricks/js)
[![MIT License](https://img.shields.io/badge/License-MIT-red.svg?style=flat-square)](https://opensource.org/licenses/MIT)
Please see [Formbricks Docs](https://formbricks.com/docs).
Specifically, [Quickstart/Implementation details](https://formbricks.com/docs/getting-started/quickstart-in-app-survey).
## What is Formbricks
Formbricks is your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 For more information please check out [formbricks.com](https://formbricks.com).
## How to use this library
1. Install the Formbricks package inside your project using npm:
```bash
npm install @formbricks/js
```
1. Import Formbricks and initialize the widget in your main component (e.g., App.tsx or App.js):
```javascript
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "your-environment-id",
appUrl: "https://app.formbricks.com",
});
}
```
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Setup Checklist** in the Formbricks settings. If you want to use the user identification feature, please check out [our docs for details](https://formbricks.com/docs/app-surveys/user-identification).
For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).

View File

@@ -1,50 +0,0 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "4.1.0",
"description": "Formbricks-js allows you to connect your index to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
"type": "git",
"url": "https://github.com/formbricks/formbricks"
},
"keywords": [
"Formbricks",
"surveys",
"experience management"
],
"sideEffects": false,
"files": [
"dist"
],
"type": "module",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"typesVersions": {
"*": {
"*": [
"./dist/index.d.ts"
]
}
},
"scripts": {
"dev": "vite build --watch --mode dev",
"build": "tsc && vite build",
"build:dev": "tsc && vite build --mode dev",
"go": "vite build --watch --mode dev",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"clean": "rimraf .turbo node_modules dist coverage"
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@formbricks/js-core": "workspace:*",
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"terser": "5.39.0",
"vite": "6.2.5",
"vite-plugin-dts": "4.5.3"
}
}

View File

@@ -1,23 +0,0 @@
import { type TFormbricks as TFormbricksCore } from "@formbricks/js-core";
import { loadFormbricksToProxy } from "./lib/load-formbricks";
type TFormbricks = Omit<TFormbricksCore, "track"> & {
track: (code: string) => Promise<void>;
};
declare global {
interface Window {
formbricks: TFormbricks | undefined;
}
}
const formbricksProxyHandler: ProxyHandler<TFormbricks> = {
get(_target, prop, _receiver) {
return (...args: unknown[]) => loadFormbricksToProxy(prop as string, ...args);
},
};
const formbricks: TFormbricksCore = new Proxy({} as TFormbricks, formbricksProxyHandler);
// eslint-disable-next-line import/no-default-export -- Required for UMD
export default formbricks;

View File

@@ -1,108 +0,0 @@
/*
eslint-disable no-console --
* Required for logging errors
*/
type Result<T, E = Error> = { ok: true; data: T } | { ok: false; error: E };
let isInitializing = false;
let isInitialized = false;
// Load the SDK, return the result
const loadFormbricksSDK = async (apiHostParam: string): Promise<Result<void>> => {
if (!window.formbricks) {
const scriptTag = document.createElement("script");
scriptTag.type = "text/javascript";
scriptTag.src = `${apiHostParam}/js/formbricks.umd.cjs`;
scriptTag.async = true;
const getFormbricks = async (): Promise<void> =>
new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Formbricks SDK loading timed out`));
}, 10000);
scriptTag.onload = () => {
clearTimeout(timeoutId);
resolve();
};
scriptTag.onerror = () => {
clearTimeout(timeoutId);
reject(new Error(`Failed to load Formbricks SDK`));
};
});
document.head.appendChild(scriptTag);
try {
await getFormbricks();
return { ok: true, data: undefined };
} catch (error) {
const err = error as { message?: string };
return {
ok: false,
error: new Error(err.message ?? `Failed to load Formbricks SDK`),
};
}
}
return { ok: true, data: undefined };
};
const functionsToProcess: { prop: string; args: unknown[] }[] = [];
export const loadFormbricksToProxy = async (prop: string, ...args: unknown[]): Promise<void> => {
// all of this should happen when not initialized:
if (!isInitialized) {
// We need to still support init for backwards compatibility
// but we should log a warning that the init method is deprecated
if (prop === "setup") {
if (isInitializing) {
console.warn("🧱 Formbricks - Warning: Formbricks is already initializing.");
return;
}
// reset the initialization state
isInitializing = true;
const argsTyped = args[0] as { appUrl: string; environmentId: string };
const { appUrl, environmentId } = argsTyped;
if (!appUrl) {
console.error("🧱 Formbricks - Error: appUrl is required");
return;
}
if (!environmentId) {
console.error("🧱 Formbricks - Error: environmentId is required");
return;
}
const loadSDKResult = await loadFormbricksSDK(appUrl);
if (loadSDKResult.ok) {
if (window.formbricks) {
const formbricksInstance = window.formbricks;
// @ts-expect-error -- Required for dynamic function calls
void formbricksInstance.setup(...args);
isInitializing = false;
isInitialized = true;
// process the queued functions
for (const { prop: functionProp, args: functionArgs } of functionsToProcess) {
if (typeof formbricksInstance[functionProp as keyof typeof formbricksInstance] !== "function") {
console.error(`🧱 Formbricks - Error: Method ${functionProp} does not exist on formbricks`);
continue;
}
// @ts-expect-error -- Required for dynamic function calls
(formbricksInstance[functionProp] as unknown)(...functionArgs);
}
}
}
} else {
console.warn(
"🧱 Formbricks - Warning: Formbricks not initialized. This method will be queued and executed after initialization."
);
functionsToProcess.push({ prop, args });
}
} else if (window.formbricks) {
// Access the default export for initialized state too
const formbricksInstance = window.formbricks;
type Formbricks = typeof formbricksInstance;
type FunctionProp = keyof Formbricks;
const functionPropTyped = prop as FunctionProp;
// @ts-expect-error -- Required for dynamic function calls
await formbricksInstance[functionPropTyped](...args);
}
};

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"declaration": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@formbricks/js-core/*": ["../js-core/dist/*"]
},
"resolveJsonModule": true,
"strict": true
},
"extends": "@formbricks/config-typescript/js-library.json",
"include": ["src", "package.json"]
}

View File

@@ -1,23 +0,0 @@
import { resolve } from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
const config = () => {
return defineConfig({
build: {
emptyOutDir: false, // keep the dist folder to avoid errors with pnpm go when folder is empty during build
minify: "terser",
sourcemap: true,
lib: {
// Could also be a dictionary or array of multiple entry points
entry: resolve(__dirname, "src/index.ts"),
name: "formbricksJsWrapper",
formats: ["es", "cjs"],
fileName: "index",
},
},
plugins: [dts({ rollupTypes: true, bundledPackages: ["@formbricks/js-core"] })],
});
};
export default config;

View File

@@ -40,9 +40,9 @@
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"vite": "^6.2.4",
"vite": "6.3.5",
"@formbricks/config-typescript": "workspace:*",
"vitest": "3.1.1",
"vitest": "3.1.3",
"@formbricks/eslint-config": "workspace:*",
"vite-plugin-dts": "4.5.3"
}

View File

@@ -55,7 +55,7 @@
"serve": "14.2.4",
"tailwindcss": "3.4.16",
"terser": "5.39.0",
"vite": "6.2.5",
"vite": "6.3.5",
"vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4"
},

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