mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 22:20:52 -06:00
Compare commits
13 Commits
docker-pac
...
fix/build-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5610c8128 | ||
|
|
3b1cddb9ce | ||
|
|
bd22aaaa86 | ||
|
|
e0e42d2eed | ||
|
|
616210f1bf | ||
|
|
ff2e7f6cc7 | ||
|
|
d1ce037f7d | ||
|
|
91f87f4b7b | ||
|
|
61657b9f9a | ||
|
|
476d032642 | ||
|
|
7538e570c5 | ||
|
|
66fcf4b79b | ||
|
|
21371b1815 |
13
.env.example
13
.env.example
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/next.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
36
apps/demo/.gitignore
vendored
36
apps/demo/.gitignore
vendored
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
5
apps/demo/next-env.d.ts
vendored
5
apps/demo/next-env.d.ts
vendored
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 'Reset' 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 'Free'
|
||||
</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>{" "}
|
||||
'Plan' to 'Free'. 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 'Paid'
|
||||
</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>{" "}
|
||||
'Plan' to 'Paid'. 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>{" "}
|
||||
'test@web.com'
|
||||
</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 'one', 'two', 'three'.
|
||||
</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 'de'
|
||||
</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 'de'.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@formbricks/config-typescript/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>,
|
||||
}));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
77
apps/web/modules/api/v2/lib/question.ts
Normal file
77
apps/web/modules/api/v2/lib/question.ts
Normal 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;
|
||||
}
|
||||
150
apps/web/modules/api/v2/lib/tests/question.test.ts
Normal file
150
apps/web/modules/api/v2/lib/tests/question.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
153
apps/web/modules/auth/signup/lib/__tests__/team.test.ts
Normal file
153
apps/web/modules/auth/signup/lib/__tests__/team.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
71
apps/web/modules/ee/sso/lib/organization.test.ts
Normal file
71
apps/web/modules/ee/sso/lib/organization.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
27
apps/web/modules/ee/sso/lib/organization.ts
Normal file
27
apps/web/modules/ee/sso/lib/organization.ts
Normal 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: [],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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: {
|
||||
|
||||
113
apps/web/modules/ee/sso/lib/team.ts
Normal file
113
apps/web/modules/ee/sso/lib/team.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
101
apps/web/modules/ee/sso/lib/tests/__mock__/team.mock.ts
Normal file
101
apps/web/modules/ee/sso/lib/tests/__mock__/team.mock.ts
Normal 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,
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
180
apps/web/modules/ee/sso/lib/tests/team.test.ts
Normal file
180
apps/web/modules/ee/sso/lib/tests/team.test.ts
Normal 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()]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ icon: "code"
|
||||
|
||||
#### Custom Configurations
|
||||
|
||||
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.
|
||||
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 machine’s 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 we’ll try our best to work out a solution with you.
|
||||
|
||||
11
package.json
11
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/library.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
4
packages/js/.gitignore
vendored
4
packages/js/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
.vscode
|
||||
build
|
||||
dist
|
||||
@@ -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.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Formbricks Browser JS Library
|
||||
|
||||
[](https://www.npmjs.com/package/@formbricks/js)
|
||||
[](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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
1
packages/js/src/vite-env.d.ts
vendored
1
packages/js/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user