Compare commits

..

36 Commits

Author SHA1 Message Date
Dhruwang
9c63f663b5 basic setup 2025-03-03 15:13:48 +05:30
Matti Nannt
8c07e8b1a8 chore: bump version to 3.3.0 (#4834) 2025-03-01 09:03:42 +01:00
Anshuman Pandey
e94b0845a2 fix: surveys package api calls and styling (#4826)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-01 05:38:43 +00:00
Matti Nannt
4acc85bd12 docs: update kubernetes deployment page (#4835) 2025-02-28 20:38:02 +01:00
Anshuman Pandey
ffa534d5eb fix: cached service in attributes endpoint (#4728)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-02-28 17:00:08 +00:00
Matti Nannt
fccf0f1e39 docs: add smtp configuration page for self-hosters (#4833) 2025-02-28 17:06:21 +01:00
Dhruwang Jariwala
a5d80d1f02 docs: kubernetes (#4830) 2025-02-28 15:34:49 +00:00
Piyush Gupta
803a73afb6 feat: Adds SAML SSO auth using boxyHQ jackson for self-hosters (#4799)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2025-02-28 12:18:59 +00:00
Dhruwang Jariwala
1eb8049d04 chore: Upgrade helm chart (#4808) 2025-02-28 11:51:58 +00:00
Matti Nannt
f9ed0c487f chore: exclude test files from coverage report (#4831) 2025-02-28 12:32:58 +01:00
Anshuman Pandey
fa7d33351f fix: local docker image build (#4758)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-02-28 10:53:23 +00:00
Gaurav Singh
e3084760b8 fix(filter-dropdown): added the search filter in filter dropdown (#4812)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-02-28 10:26:36 +00:00
mintlify[bot]
8e5addad5c docs: Update license page (#4827)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-27 19:08:25 +00:00
Dhruwang Jariwala
6e741018e5 fix: Tweak progress bar (#4820) 2025-02-27 17:51:54 +00:00
Piyush Gupta
98c7c78421 fix: a11y in file upload (#4742)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-02-27 17:23:25 +00:00
Dhruwang Jariwala
16c588138c fix: branch source name (#4825) 2025-02-26 14:32:14 +00:00
Dhruwang Jariwala
1373863af5 fix: current branch name (#4823) 2025-02-26 13:39:09 +00:00
Dhruwang Jariwala
75315ea2c5 fix: tolgee tweaks (#4809)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-02-26 12:06:25 +00:00
Dhruwang Jariwala
9f6fb8a387 feat: optional back button (#4813)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2025-02-26 11:36:16 +00:00
Piyush Gupta
b84d3d5806 fix: hidden field summary row key (#4821) 2025-02-26 11:05:25 +00:00
Dhruwang Jariwala
5c2c1bbfcd fix: removed remove-unused flag (#4814) 2025-02-25 12:52:29 +00:00
Piyush Gupta
54e84858b5 docs: adds query param in single-use-id docs (#4811) 2025-02-25 11:18:59 +00:00
Piyush Gupta
833d0789d7 fix: oauth docs formatting (#4807) 2025-02-25 09:47:52 +00:00
Dhruwang Jariwala
1a974f3dd8 fix: survey placement and close on click outside (#4745)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-02-25 08:33:52 +00:00
Salim B
146173883f docs: Fix formatting and other small tweaks (#4798)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2025-02-25 06:09:45 +00:00
Johannes
ebb02a5723 docs: add suspense to RN (#4801) 2025-02-25 05:54:32 +00:00
Johannes
c96f7fed18 docs: fix images (#4800) 2025-02-24 09:51:15 +00:00
mintlify[bot]
861eff3cd2 docs: Update menu (#4793)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-21 04:52:05 -08:00
Piyush Gupta
b66c0d17d0 feat: adds Is set and Is not set operator for hidden fields in logic editor (#4785)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-02-21 11:47:31 +00:00
Dhruwang Jariwala
0e748050f3 fix: tolgee flow (#4765) 2025-02-21 08:56:06 +00:00
Matti Nannt
ae3524b79f chore: add api v2 draft docs (#4783)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-02-20 16:03:01 +01:00
mintlify[bot]
0ce58b592a docs: fix images in actions and targeting (#4781)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-20 05:21:27 -08:00
mintlify[bot]
578346840e docs: Rewrite Actions and Advanced Targeting Docs (#4779)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-02-20 12:49:13 +00:00
Piyush Gupta
56bcb46d6c fix: User Management Docs (#4775)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-02-20 09:28:14 +00:00
Piyush Gupta
91405c48e0 chore: remove SHORT_URL_BASE env key (#4766) 2025-02-20 08:22:09 +00:00
Paribesh Nepal
b40dff621a feat: Support HEIC format for images (#4719)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-02-20 04:00:49 +00:00
208 changed files with 6048 additions and 2097 deletions

View File

@@ -1,39 +1,56 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
# **/node_modules
**/node_modules
.pnp
.pnp.js
.pnpm-store/
# testing
coverage
**/coverage
# next.js
**/.next
**/out
**/.next/
**/out/
**/build
# node
**/dist
**/dist/
# misc
.DS_Store
**/.DS_Store
*.pem
Zone.Identifier
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# turbo
.turbo
# local env files
**/.env
**/.env.local
**/.env.development.local
**/.env.test.local
**/.env.production.local
!packages/database/.env
!apps/web/.env
# nixos stuff
# build tools
.turbo
**/*vite.config.*.timestamp-*
# environment specific
.direnv
.vscode
.github
**/.turbo
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
.env
# project specific
packages/lib/uploads
apps/web/public/js
packages/database/migrations
branch.json

View File

@@ -130,6 +130,9 @@ AZUREAD_TENANT_ID=
# OIDC_DISPLAY_NAME=
# OIDC_SIGNING_ALGORITHM=
# Configure SAML SSO
# SAML_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks-saml
# Configure this when you want to ship JS & CSS files from a complete URL instead of the current domain
# ASSET_PREFIX_URL=

View File

@@ -3,7 +3,8 @@ permissions:
contents: read
on:
push:
pull_request:
types: [closed]
branches:
- main
@@ -11,10 +12,30 @@ jobs:
tag-production-keys:
name: Tag Production Keys
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # This ensures we get the full git history
- name: Get source branch name
id: branch-name
run: |
# For PR merges, use the head ref from the pull request event
SOURCE_BRANCH="${{ github.head_ref }}"
# Only remove username prefix if needed
if [[ "$SOURCE_BRANCH" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]+/ ]]; then
PREFIX=${SOURCE_BRANCH%%/*}
if [[ ! "$PREFIX" =~ ^(feature|fix|bugfix|hotfix|release|chore|docs|test|refactor|style|perf|build|ci|revert)$ ]]; then
SOURCE_BRANCH=${SOURCE_BRANCH#*/}
fi
fi
echo "SOURCE_BRANCH=$SOURCE_BRANCH" >> $GITHUB_ENV
echo "Detected source branch: $SOURCE_BRANCH"
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -26,17 +47,38 @@ jobs:
- name: Tag Production Keys
run: |
BRANCH_NAME=${GITHUB_REF##*/}
npx tolgee tag \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-extracted \
--filter-tag "draft: ${BRANCH_NAME}" \
--filter-tag "draft:${SOURCE_BRANCH}" \
--tag production \
--untag "draft: ${BRANCH_NAME}"
--untag "draft:${SOURCE_BRANCH}"
- name: Tag Deprecated Keys
- name: Tag unused production keys as Deprecated
run: |
npx tolgee tag \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag production \
--tag deprecated --untag production
- name: Tag unused draft:current-branch keys as Deprecated
run: |
npx tolgee tag \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag "draft:${SOURCE_BRANCH}" \
--tag deprecated --untag "draft:${SOURCE_BRANCH}"
- name: Sync with backup
run: |
npx tolgee sync \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--backup ./tolgee-backup \
--continue-on-warning \
--yes
- name: Upload backup as artifact
uses: actions/upload-artifact@v4
with:
name: tolgee-backup-${{ github.sha }}
path: ./tolgee-backup
retention-days: 90

44
.gitignore vendored
View File

@@ -1,25 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
**/node_modules
.pnp
.pnp.js
.pnpm-store/
# testing
coverage
**/coverage
# next.js
.next/
out/
build
**/.next/
**/out/
**/build
# node
dist/
**/dist/
# misc
.DS_Store
**/.DS_Store
*.pem
Zone.Identifier
# debug
npm-debug.log*
@@ -27,36 +28,29 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
**/.env
**/.env.local
**/.env.development.local
**/.env.test.local
**/.env.production.local
!packages/database/.env
!apps/web/.env
# turbo
# build tools
.turbo
**/*vite.config.*.timestamp-*
# nixos stuff
# environment specific
.direnv
Zone.Identifier
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# uploads
# project specific
packages/lib/uploads
# Vite Timestamps
*vite.config.*.timestamp-*
# js compiled assets
apps/web/public/js
packages/database/migrations
packages/database/migrations
branch.json

View File

@@ -1 +1,2 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ../branch.json
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json

View File

@@ -1 +0,0 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ../branch.json

View File

@@ -1,5 +1,21 @@
pnpm lint-staged
pnpm tolgee-pull || true
echo "{\"branchName\": \"main\"}" > ../branch.json
git add branch.json packages/lib/messages/*.json
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run tolgee-pull if branch.json exists and NEXT_PUBLIC_TOLGEE_API_KEY is not set
if [ -f branch.json ]; then
if [ -z "$NEXT_PUBLIC_TOLGEE_API_KEY" ]; then
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
else
pnpm run tolgee-pull
git add packages/lib/messages
fi
fi

View File

@@ -13,7 +13,7 @@
<h3 align="center">Formbricks</h3>
<p align="center">
Harvest user-insights, build irresistible experiences.
The Open Source Qualtrics Alternative
<br />
<a href="https://formbricks.com/">Website</a>
</p>

3
apps/web/.gitignore vendored
View File

@@ -48,3 +48,6 @@ uploads/
# Sentry Config File
.sentryclirc
# SAML Preloaded Connections
saml-connection/

View File

@@ -33,6 +33,9 @@ ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runt
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit
# ENV NODE_OPTIONS="--max_old_space_size=4096"
# Set the working directory
WORKDIR /app
@@ -41,19 +44,17 @@ WORKDIR /app
# COPY --from=builder /app/out/json/ .
# COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Install the dependencies
# RUN pnpm install
# Prepare the build
COPY . .
# Create a .env file
RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install
# Build the project
# RUN pnpm post-install --filter=@formbricks/web...
RUN pnpm build --filter=@formbricks/web...
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
# Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
@@ -76,6 +77,7 @@ WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
@@ -105,6 +107,11 @@ ENV HOSTNAME "0.0.0.0"
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD supercronic -quiet /app/docker/cronjobs & \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js

View File

@@ -7,6 +7,7 @@ import {
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
@@ -19,7 +20,6 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TIntegrationInput } from "@formbricks/types/integration";
import {

View File

@@ -1,6 +1,7 @@
"use client";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -12,7 +13,6 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { processResponseData } from "@formbricks/lib/responses";
import { getContactIdentifier } from "@formbricks/lib/utils/contact";
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";

View File

@@ -51,9 +51,9 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
<div
key={response.value}
key={`${response.value}-${idx}`}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (

View File

@@ -1,9 +1,9 @@
"use client";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";

View File

@@ -1,10 +1,10 @@
"use client";
import { getQuestionIcon } from "@/modules/survey/lib/questions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { TimerIcon } from "lucide-react";
import { JSX } from "react";
import { getQuestionIcon } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";

View File

@@ -14,6 +14,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
@@ -48,6 +49,7 @@ export const QuestionFilterComboBox = ({
const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
const commandRef = React.useRef(null);
const [searchQuery, setSearchQuery] = React.useState<string>("");
const defaultLanguageCode = "default";
useClickOutside(commandRef, () => setOpen(false));
const { t } = useTranslate();
@@ -73,6 +75,12 @@ export const QuestionFilterComboBox = ({
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
const filteredOptions = options?.filter((o) =>
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
return (
<div className="inline-flex w-full flex-row">
{filterOptions && filterOptions?.length <= 1 ? (
@@ -160,10 +168,21 @@ export const QuestionFilterComboBox = ({
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{options?.map((o) => (
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => {
!isMultiple
? onChangeFilterComboBoxValue(

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/ee/auth/saml/api/authorize/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { POST } from "@/modules/ee/auth/saml/api/callback/route";
export { POST };

View File

@@ -0,0 +1,3 @@
import { POST } from "@/modules/ee/auth/saml/api/token/route";
export { POST };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/ee/auth/saml/api/userinfo/route";
export { GET };

View File

@@ -61,6 +61,7 @@ export const getSurveysForEnvironmentState = reactCache(
displayLimit: true,
displayOption: true,
hiddenFields: true,
isBackButtonHidden: true,
triggers: {
select: {
actionClass: {
@@ -72,6 +73,7 @@ export const getSurveysForEnvironmentState = reactCache(
},
displayPercentage: true,
delay: true,
projectOverwrites: true,
},
});

View File

@@ -1,17 +0,0 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { XCircleIcon } from "lucide-react";
const Error = ({ error }: { error: Error & { digest?: string } }) => {
const { t } = useTranslate();
return (
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center text-center">
<XCircleIcon height={40} color="red" />
<p className="text-md mt-4 font-bold text-zinc-900">{t("health.degraded")}</p>
<p className="text-sm text-zinc-900">{error.message}</p>
</div>
);
};
export default Error;

View File

@@ -1,54 +0,0 @@
import { getTranslate } from "@/tolgee/server";
import { BadgeCheckIcon } from "lucide-react";
import { Metadata } from "next";
import { prisma } from "@formbricks/database";
export const dynamic = "force-dynamic"; // no caching
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
const checkDatabaseConnection = async () => {
try {
await prisma.$queryRaw`SELECT 1`;
} catch (e) {
console.error("Database connection error:", e);
throw new Error("Database could not be reached");
}
};
/* const checkS3Connection = async () => {
if (!IS_S3_CONFIGURED) {
// dont try connecting if not in use
return;
}
try {
await testS3BucketAccess();
} catch (e) {
throw new Error("S3 Bucket cannot be accessed");
}
}; */
const Page = async () => {
const t = await getTranslate();
await checkDatabaseConnection();
// Skipping S3 check for now until it's fixed
// await checkS3Connection();
return (
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center text-center">
<BadgeCheckIcon height={40} color="green" />
<p className="text-md mt-4 font-bold text-zinc-900">{t("health.healthy")}</p>
</div>
);
};
export default Page;

View File

@@ -0,0 +1,3 @@
export async function GET() {
return Response.json({ status: "ok" });
}

View File

@@ -7064,5 +7064,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
triggers: [],
showLanguageSwitch: false,
followUps: [],
isBackButtonHidden: false,
} as TSurvey;
};

View File

@@ -0,0 +1,40 @@
"use server";
import { CRON_SECRET } from "@formbricks/lib/constants";
import { getTelemetryId } from "@formbricks/lib/telemetry";
// Send admin account information to n8n webhook
export const notifyAdminCreation = async (email: string): Promise<void> => {
try {
if (process.env.NODE_ENV !== "production") {
return;
}
// Get the instance ID (using telemetry ID function which hashes CRON_SECRET)
const instanceId = getTelemetryId();
// Send data to n8n webhook
const response = await fetch(
"https://n8n.formbricks.com/webhook-test/6a107551-e368-425f-81e7-05e680cf21e8",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
email,
instanceId,
timestamp: new Date().toISOString(),
}),
}
);
if (!response.ok) {
console.error(`Failed to notify admin creation: ${response.statusText}`);
}
} catch (error) {
// Don't throw errors - just log them to avoid disrupting the signup flow
console.error("Error notifying admin creation:", error);
// We're intentionally not throwing here to prevent signup flow disruption
}
};

View File

@@ -39,7 +39,10 @@ interface LoginFormProps {
oidcOAuthEnabled: boolean;
oidcDisplayName?: string;
isMultiOrgEnabled: boolean;
isSSOEnabled: boolean;
isSsoEnabled: boolean;
samlSsoEnabled: boolean;
samlTenant: string;
samlProduct: string;
}
export const LoginForm = ({
@@ -52,7 +55,10 @@ export const LoginForm = ({
oidcOAuthEnabled,
oidcDisplayName,
isMultiOrgEnabled,
isSSOEnabled,
isSsoEnabled,
samlSsoEnabled,
samlTenant,
samlProduct,
}: LoginFormProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -239,13 +245,16 @@ export const LoginForm = ({
</Button>
)}
</form>
{isSSOEnabled && (
{isSsoEnabled && (
<SSOOptions
googleOAuthEnabled={googleOAuthEnabled}
githubOAuthEnabled={githubOAuthEnabled}
azureOAuthEnabled={azureOAuthEnabled}
oidcOAuthEnabled={oidcOAuthEnabled}
oidcDisplayName={oidcDisplayName}
samlSsoEnabled={samlSsoEnabled}
samlTenant={samlTenant}
samlProduct={samlProduct}
callbackUrl={callbackUrl}
/>
)}

View File

@@ -1,6 +1,10 @@
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { Testimonial } from "@/modules/auth/components/testimonial";
import { getIsMultiOrgEnabled, getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { Metadata } from "next";
import {
AZURE_OAUTH_ENABLED,
@@ -10,6 +14,9 @@ import {
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PASSWORD_RESET_DISABLED,
SAML_OAUTH_ENABLED,
SAML_PRODUCT,
SAML_TENANT,
SIGNUP_ENABLED,
} from "@formbricks/lib/constants";
import { LoginForm } from "./components/login-form";
@@ -20,7 +27,13 @@ export const metadata: Metadata = {
};
export const LoginPage = async () => {
const [isMultiOrgEnabled, isSSOEnabled] = await Promise.all([getIsMultiOrgEnabled(), getIsSSOEnabled()]);
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
getIsMultiOrgEnabled(),
getisSsoEnabled(),
getIsSamlSsoEnabled(),
]);
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
return (
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
<div className="col-span-2 hidden lg:flex">
@@ -38,7 +51,10 @@ export const LoginPage = async () => {
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
oidcDisplayName={OIDC_DISPLAY_NAME}
isMultiOrgEnabled={isMultiOrgEnabled}
isSSOEnabled={isSSOEnabled}
isSsoEnabled={isSsoEnabled}
samlSsoEnabled={samlSsoEnabled}
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
/>
</FormWrapper>
</div>

View File

@@ -1,6 +1,7 @@
"use server";
import { actionClient } from "@/lib/utils/action-client";
import { notifyAdminCreation } from "@/modules/auth/lib/admin-notification";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
@@ -10,6 +11,7 @@ import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email"
import { z } from "zod";
import { hashPassword } from "@formbricks/lib/auth";
import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@formbricks/lib/constants";
import { getIsFreshInstance } from "@formbricks/lib/instance/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization, getOrganization } from "@formbricks/lib/organization/service";
@@ -131,6 +133,19 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
),
},
});
const isFreshInstance = await getIsFreshInstance();
console.log("isFreshInstance", isFreshInstance);
console.log("role", role);
// If this is a fresh setup (role is owner), notify about admin creation
if (role === "owner" && isFreshInstance) {
try {
await notifyAdminCreation(user.email);
} catch (error) {
// Log error but continue with signup
console.error("Failed to notify about admin creation:", error);
}
}
}
}

View File

@@ -53,8 +53,11 @@ interface SignupFormProps {
emailVerificationDisabled: boolean;
defaultOrganizationId?: string;
defaultOrganizationRole?: TOrganizationRole;
isSSOEnabled: boolean;
isSsoEnabled: boolean;
samlSsoEnabled: boolean;
isTurnstileConfigured: boolean;
samlTenant: string;
samlProduct: string;
}
export const SignupForm = ({
@@ -72,8 +75,11 @@ export const SignupForm = ({
emailVerificationDisabled,
defaultOrganizationId,
defaultOrganizationRole,
isSSOEnabled,
isSsoEnabled,
samlSsoEnabled,
isTurnstileConfigured,
samlTenant,
samlProduct,
}: SignupFormProps) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
@@ -266,13 +272,16 @@ export const SignupForm = ({
</form>
</FormProvider>
)}
{isSSOEnabled && (
{isSsoEnabled && (
<SSOOptions
googleOAuthEnabled={googleOAuthEnabled}
githubOAuthEnabled={githubOAuthEnabled}
azureOAuthEnabled={azureOAuthEnabled}
oidcOAuthEnabled={oidcOAuthEnabled}
oidcDisplayName={oidcDisplayName}
samlSsoEnabled={samlSsoEnabled}
samlTenant={samlTenant}
samlProduct={samlProduct}
callbackUrl={callbackUrl}
/>
)}

View File

@@ -1,6 +1,10 @@
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { Testimonial } from "@/modules/auth/components/testimonial";
import { getIsMultiOrgEnabled, getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { notFound } from "next/navigation";
import {
AZURE_OAUTH_ENABLED,
@@ -14,6 +18,9 @@ import {
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
SAML_OAUTH_ENABLED,
SAML_PRODUCT,
SAML_TENANT,
SIGNUP_ENABLED,
TERMS_URL,
WEBAPP_URL,
@@ -24,7 +31,14 @@ import { SignupForm } from "./components/signup-form";
export const SignupPage = async ({ searchParams: searchParamsProps }) => {
const searchParams = await searchParamsProps;
const inviteToken = searchParams["inviteToken"] ?? null;
const [isMultOrgEnabled, isSSOEnabled] = await Promise.all([getIsMultiOrgEnabled(), getIsSSOEnabled()]);
const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
getIsMultiOrgEnabled(),
getisSsoEnabled(),
getIsSamlSsoEnabled(),
]);
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
const locale = await findMatchingLocale();
if (!inviteToken && (!SIGNUP_ENABLED || !isMultOrgEnabled)) {
notFound();
@@ -53,8 +67,11 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
emailFromSearchParams={emailFromSearchParams}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isSsoEnabled={isSsoEnabled}
samlSsoEnabled={samlSsoEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
/>
</FormWrapper>
</div>

View File

@@ -0,0 +1,33 @@
import { responses } from "@/app/lib/api/response";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import type { OAuthReq } from "@boxyhq/saml-jackson";
import { NextRequest, NextResponse } from "next/server";
export const GET = async (req: NextRequest) => {
const jacksonInstance = await jackson();
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { oauthController } = jacksonInstance;
const searchParams = Object.fromEntries(req.nextUrl.searchParams);
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
try {
const { redirect_url } = await oauthController.authorize(searchParams as OAuthReq);
if (!redirect_url) {
return responses.internalServerErrorResponse("Failed to get redirect URL");
}
return NextResponse.redirect(redirect_url);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : "An unknown error occurred";
return responses.internalServerErrorResponse(errorMessage);
}
};

View File

@@ -0,0 +1,32 @@
import { responses } from "@/app/lib/api/response";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
import { redirect } from "next/navigation";
interface SAMLCallbackBody {
RelayState: string;
SAMLResponse: string;
}
export const POST = async (req: Request) => {
const jacksonInstance = await jackson();
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { oauthController } = jacksonInstance;
const formData = await req.formData();
const body = Object.fromEntries(formData.entries());
const { RelayState, SAMLResponse } = body as unknown as SAMLCallbackBody;
const { redirect_url } = await oauthController.samlResponse({
RelayState,
SAMLResponse,
});
if (!redirect_url) {
return responses.internalServerErrorResponse("Failed to get redirect URL");
}
return redirect(redirect_url);
};

View File

@@ -0,0 +1,18 @@
import { responses } from "@/app/lib/api/response";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
export const POST = async (req: Request) => {
const jacksonInstance = await jackson();
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { oauthController } = jacksonInstance;
const body = await req.formData();
const formData = Object.fromEntries(body.entries());
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
return Response.json(response);
};

View File

@@ -0,0 +1,87 @@
import { responses } from "@/app/lib/api/response";
import { describe, expect, test, vi } from "vitest";
import { extractAuthToken } from "./utils";
vi.mock("@/app/lib/api/response", () => ({
responses: {
unauthorizedResponse: vi.fn().mockReturnValue(new Error("Unauthorized")),
},
}));
describe("extractAuthToken", () => {
test("extracts token from Authorization header with Bearer prefix", () => {
const mockRequest = new Request("https://example.com", {
headers: {
authorization: "Bearer token123",
},
});
const token = extractAuthToken(mockRequest);
expect(token).toBe("token123");
});
test("extracts token from Authorization header with other prefix", () => {
const mockRequest = new Request("https://example.com", {
headers: {
authorization: "Custom token123",
},
});
const token = extractAuthToken(mockRequest);
expect(token).toBe("token123");
});
test("extracts token from query parameter", () => {
const mockRequest = new Request("https://example.com?access_token=token123");
const token = extractAuthToken(mockRequest);
expect(token).toBe("token123");
});
test("prioritizes Authorization header over query parameter", () => {
const mockRequest = new Request("https://example.com?access_token=queryToken", {
headers: {
authorization: "Bearer headerToken",
},
});
const token = extractAuthToken(mockRequest);
expect(token).toBe("headerToken");
});
test("throws unauthorized error when no token is found", () => {
const mockRequest = new Request("https://example.com");
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
expect(responses.unauthorizedResponse).toHaveBeenCalled();
});
test("throws unauthorized error when Authorization header is empty", () => {
const mockRequest = new Request("https://example.com", {
headers: {
authorization: "",
},
});
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
expect(responses.unauthorizedResponse).toHaveBeenCalled();
});
test("throws unauthorized error when query parameter is empty", () => {
const mockRequest = new Request("https://example.com?access_token=");
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
expect(responses.unauthorizedResponse).toHaveBeenCalled();
});
test("handles Authorization header with only prefix", () => {
const mockRequest = new Request("https://example.com", {
headers: {
authorization: "Bearer ",
},
});
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
expect(responses.unauthorizedResponse).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,14 @@
import { responses } from "@/app/lib/api/response";
export const extractAuthToken = (req: Request) => {
const authHeader = req.headers.get("authorization");
const parts = (authHeader || "").split(" ");
if (parts.length > 1) return parts[1];
// check for query param
const params = new URL(req.url).searchParams;
const accessToken = params.get("access_token");
if (accessToken) return accessToken;
throw responses.unauthorizedResponse();
};

View File

@@ -0,0 +1,16 @@
import { responses } from "@/app/lib/api/response";
import { extractAuthToken } from "@/modules/ee/auth/saml/api/userinfo/lib/utils";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
export const GET = async (req: Request) => {
const jacksonInstance = await jackson();
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { oauthController } = jacksonInstance;
const token = extractAuthToken(req);
const user = await oauthController.userInfo(token);
return Response.json(user);
};

View File

@@ -0,0 +1,43 @@
"use server";
import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection";
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import type { IConnectionAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants";
const opts: JacksonOption = {
externalUrl: WEBAPP_URL,
samlAudience: SAML_AUDIENCE,
samlPath: SAML_PATH,
db: {
engine: "sql",
type: "postgres",
url: SAML_DATABASE_URL,
},
};
declare global {
var oauthController: IOAuthController | undefined;
var connectionController: IConnectionAPIController | undefined;
}
const g = global;
export default async function init() {
if (!g.oauthController || !g.connectionController) {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) return;
const ret = await (await import("@boxyhq/saml-jackson")).controllers(opts);
await preloadConnection(ret.connectionAPIController);
g.oauthController = ret.oauthController;
g.connectionController = ret.connectionAPIController;
}
return {
oauthController: g.oauthController,
connectionController: g.connectionController,
};
}

View File

@@ -0,0 +1,73 @@
import { SAMLSSOConnectionWithEncodedMetadata, SAMLSSORecord } from "@boxyhq/saml-jackson";
import { ConnectionAPIController } from "@boxyhq/saml-jackson/dist/controller/api";
import fs from "fs/promises";
import path from "path";
import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
const getPreloadedConnectionFile = async () => {
const preloadedConnections = await fs.readdir(path.join(SAML_XML_DIR));
const xmlFiles = preloadedConnections.filter((file) => file.endsWith(".xml"));
if (xmlFiles.length === 0) {
throw new Error("No preloaded connection file found");
}
return xmlFiles[0];
};
const getPreloadedConnectionMetadata = async () => {
const preloadedConnectionFile = await getPreloadedConnectionFile();
const preloadedConnectionMetadata = await fs.readFile(
path.join(SAML_XML_DIR, preloadedConnectionFile),
"utf8"
);
return preloadedConnectionMetadata;
};
const getConnectionPayload = (metadata: string): SAMLSSOConnectionWithEncodedMetadata => {
const encodedRawMetadata = Buffer.from(metadata, "utf8").toString("base64");
return {
name: "SAML SSO",
defaultRedirectUrl: `${WEBAPP_URL}/auth/login`,
redirectUrl: [`${WEBAPP_URL}/*`],
tenant: SAML_TENANT,
product: SAML_PRODUCT,
encodedRawMetadata,
};
};
export const preloadConnection = async (connectionController: ConnectionAPIController) => {
try {
const preloadedConnectionMetadata = await getPreloadedConnectionMetadata();
if (!preloadedConnectionMetadata) {
console.log("No preloaded connection metadata found");
return;
}
const connections = await connectionController.getConnections({
tenant: SAML_TENANT,
product: SAML_PRODUCT,
});
const existingConnection = connections[0];
const connection = getConnectionPayload(preloadedConnectionMetadata);
let newConnection: SAMLSSORecord;
try {
newConnection = await connectionController.createSAMLConnection(connection);
} catch (error) {
throw new Error(`Metadata is not valid\n${error.message}`);
}
if (newConnection && existingConnection && newConnection.clientID !== existingConnection.clientID) {
await connectionController.deleteConnections({
clientID: existingConnection.clientID,
clientSecret: existingConnection.clientSecret,
product: existingConnection.product,
tenant: existingConnection.tenant,
});
}
} catch (error) {
console.error("Error preloading connection:", error.message);
}
};

View File

@@ -0,0 +1,110 @@
import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection";
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { controllers } from "@boxyhq/saml-jackson";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants";
import init from "../jackson";
vi.mock("@formbricks/lib/constants", () => ({
SAML_AUDIENCE: "test-audience",
SAML_DATABASE_URL: "test-db-url",
SAML_PATH: "/test-path",
WEBAPP_URL: "https://test-webapp-url.com",
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
}));
vi.mock("@/modules/ee/auth/saml/lib/preload-connection", () => ({
preloadConnection: vi.fn(),
}));
vi.mock("@boxyhq/saml-jackson", () => ({
controllers: vi.fn(),
}));
describe("SAML Jackson Initialization", () => {
const mockOAuthController = { name: "mockOAuthController" };
const mockConnectionController = { name: "mockConnectionController" };
beforeEach(() => {
vi.clearAllMocks();
global.oauthController = undefined;
global.connectionController = undefined;
vi.mocked(controllers).mockResolvedValue({
oauthController: mockOAuthController,
connectionAPIController: mockConnectionController,
} as any);
});
afterEach(() => {
vi.resetAllMocks();
});
test("initialize controllers when SAML SSO is enabled", async () => {
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
const result = await init();
expect(getIsSamlSsoEnabled).toHaveBeenCalledTimes(1);
expect(controllers).toHaveBeenCalledWith({
externalUrl: WEBAPP_URL,
samlAudience: SAML_AUDIENCE,
samlPath: SAML_PATH,
db: {
engine: "sql",
type: "postgres",
url: SAML_DATABASE_URL,
},
});
expect(preloadConnection).toHaveBeenCalledWith(mockConnectionController);
expect(global.oauthController).toBe(mockOAuthController);
expect(global.connectionController).toBe(mockConnectionController);
expect(result).toEqual({
oauthController: mockOAuthController,
connectionController: mockConnectionController,
});
});
test("return early when SAML SSO is disabled", async () => {
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
const result = await init();
expect(getIsSamlSsoEnabled).toHaveBeenCalledTimes(1);
expect(controllers).not.toHaveBeenCalled();
expect(preloadConnection).not.toHaveBeenCalled();
expect(global.oauthController).toBeUndefined();
expect(global.connectionController).toBeUndefined();
expect(result).toBeUndefined();
});
test("reuse existing controllers if already initialized", async () => {
global.oauthController = mockOAuthController as any;
global.connectionController = mockConnectionController as any;
const result = await init();
expect(getIsSamlSsoEnabled).not.toHaveBeenCalled();
expect(controllers).not.toHaveBeenCalled();
expect(preloadConnection).not.toHaveBeenCalled();
expect(result).toEqual({
oauthController: mockOAuthController,
connectionController: mockConnectionController,
});
});
});

View File

@@ -0,0 +1,142 @@
import fs from "fs/promises";
import path from "path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
import { preloadConnection } from "../preload-connection";
vi.mock("@formbricks/lib/constants", () => ({
SAML_PRODUCT: "test-product",
SAML_TENANT: "test-tenant",
SAML_XML_DIR: "test-xml-dir",
WEBAPP_URL: "https://test-webapp-url.com",
}));
vi.mock("fs/promises", () => ({
default: {
readdir: vi.fn(),
readFile: vi.fn(),
},
}));
vi.mock("path", () => ({
default: {
join: vi.fn(),
},
}));
vi.mock("@boxyhq/saml-jackson", () => ({
SAMLSSOConnectionWithEncodedMetadata: vi.fn(),
}));
vi.mock("@boxyhq/saml-jackson/dist/controller/api", () => ({
ConnectionAPIController: vi.fn(),
}));
describe("SAML Preload Connection", () => {
const mockConnectionController = {
getConnections: vi.fn(),
createSAMLConnection: vi.fn(),
deleteConnections: vi.fn(),
};
const mockMetadata = "<EntityDescriptor>SAML Metadata</EntityDescriptor>";
const mockEncodedMetadata = Buffer.from(mockMetadata, "utf8").toString("base64");
const mockExistingConnection = {
clientID: "existing-client-id",
clientSecret: "existing-client-secret",
product: SAML_PRODUCT,
tenant: SAML_TENANT,
};
const mockNewConnection = {
clientID: "new-client-id",
clientSecret: "new-client-secret",
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(path.join).mockImplementation((...args) => args.join("/"));
vi.mocked(fs.readdir).mockResolvedValue(["metadata.xml", "other-file.txt"] as any);
vi.mocked(fs.readFile).mockResolvedValue(mockMetadata as any);
mockConnectionController.getConnections.mockResolvedValue([mockExistingConnection]);
mockConnectionController.createSAMLConnection.mockResolvedValue(mockNewConnection);
});
afterEach(() => {
vi.resetAllMocks();
});
test("preload connection from XML file", async () => {
await preloadConnection(mockConnectionController as any);
expect(fs.readdir).toHaveBeenCalledWith(path.join(SAML_XML_DIR));
expect(fs.readFile).toHaveBeenCalledWith(path.join(SAML_XML_DIR, "metadata.xml"), "utf8");
expect(mockConnectionController.getConnections).toHaveBeenCalledWith({
tenant: SAML_TENANT,
product: SAML_PRODUCT,
});
expect(mockConnectionController.createSAMLConnection).toHaveBeenCalledWith({
name: "SAML SSO",
defaultRedirectUrl: `${WEBAPP_URL}/auth/login`,
redirectUrl: [`${WEBAPP_URL}/*`],
tenant: SAML_TENANT,
product: SAML_PRODUCT,
encodedRawMetadata: mockEncodedMetadata,
});
expect(mockConnectionController.deleteConnections).toHaveBeenCalledWith({
clientID: mockExistingConnection.clientID,
clientSecret: mockExistingConnection.clientSecret,
product: mockExistingConnection.product,
tenant: mockExistingConnection.tenant,
});
});
test("not delete existing connection if client IDs match", async () => {
mockConnectionController.createSAMLConnection.mockResolvedValue({
clientID: mockExistingConnection.clientID,
});
await preloadConnection(mockConnectionController as any);
expect(mockConnectionController.deleteConnections).not.toHaveBeenCalled();
});
test("handle case when no XML files are found", async () => {
vi.mocked(fs.readdir).mockResolvedValue(["other-file.txt"] as any);
const consoleErrorSpy = vi.spyOn(console, "error");
await preloadConnection(mockConnectionController as any);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error preloading connection:",
expect.stringContaining("No preloaded connection file found")
);
expect(mockConnectionController.createSAMLConnection).not.toHaveBeenCalled();
});
test("handle invalid metadata", async () => {
const errorMessage = "Invalid metadata";
mockConnectionController.createSAMLConnection.mockRejectedValue(new Error(errorMessage));
const consoleErrorSpy = vi.spyOn(console, "error");
await preloadConnection(mockConnectionController as any);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error preloading connection:",
expect.stringContaining(errorMessage)
);
});
});

View File

@@ -1,7 +1,8 @@
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getTranslate } from "@/tolgee/server";
import { getResponsesByContactId } from "@formbricks/lib/response/service";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { getContact, getContactAttributes } from "../../lib/contacts";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();

View File

@@ -1,7 +1,8 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
import { DeleteContactButton } from "@/modules/ee/contacts/[contactId]/components/delete-contact-button";
import { getContact, getContactAttributes } from "@/modules/ee/contacts/lib/contacts";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";

View File

@@ -1,11 +1,12 @@
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import { prisma } from "@formbricks/database";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contact-attribute";
import { getContactAttributeKeys } from "./contacts";
export const updateAttributes = async (
contactId: string,
@@ -24,24 +25,7 @@ export const updateAttributes = async (
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
getContactAttributeKeys(environmentId),
contactAttributesParam.email
? prisma.contactAttribute.findFirst({
where: {
AND: [
{
attributeKey: {
key: "email",
},
value: contactAttributesParam.email,
},
{
NOT: {
contactId,
},
},
],
},
select: { id: true },
})
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
: Promise.resolve(null),
]);

View File

@@ -0,0 +1,20 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
export const getContactAttributeKeys = reactCache(
(environmentId: string): Promise<TContactAttributeKey[]> =>
cache(
async () => {
return await prisma.contactAttributeKey.findMany({
where: { environmentId },
});
},
[`getContactAttributeKeys-${environmentId}`],
{
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -0,0 +1,92 @@
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
const selectContactAttribute = {
value: true,
attributeKey: {
select: {
key: true,
name: true,
},
},
} satisfies Prisma.ContactAttributeSelect;
export const getContactAttributes = reactCache((contactId: string) =>
cache(
async () => {
validateInputs([contactId, ZId]);
try {
const prismaAttributes = await prisma.contactAttribute.findMany({
where: {
contactId,
},
select: selectContactAttribute,
});
return prismaAttributes.reduce((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getContactAttributes-${contactId}`],
{
tags: [contactAttributeCache.tag.byContactId(contactId)],
}
)()
);
export const hasEmailAttribute = reactCache(
async (email: string, environmentId: string, contactId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([email, ZUserEmail], [environmentId, ZId], [contactId, ZId]);
const contactAttribute = await prisma.contactAttribute.findFirst({
where: {
AND: [
{
attributeKey: {
key: "email",
environmentId,
},
value: email,
},
{
NOT: {
contactId,
},
},
],
},
select: { id: true },
});
return !!contactAttribute;
},
[`hasEmailAttribute-${email}-${environmentId}-${contactId}`],
{
tags: [
contactAttributeKeyCache.tag.byEnvironmentIdAndKey(environmentId, "email"),
contactAttributeCache.tag.byEnvironmentId(environmentId),
contactAttributeCache.tag.byContactId(contactId),
],
}
)()
);

View File

@@ -9,8 +9,6 @@ import { cache } from "@formbricks/lib/cache";
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
TContact,
@@ -39,16 +37,6 @@ const selectContact = {
},
} satisfies Prisma.ContactSelect;
const selectContactAttribute = {
value: true,
attributeKey: {
select: {
key: true,
name: true,
},
},
} satisfies Prisma.ContactAttributeSelect;
const buildContactWhereClause = (environmentId: string, search?: string): Prisma.ContactWhereInput => {
const whereClause: Prisma.ContactWhereInput = { environmentId };
@@ -149,6 +137,7 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
});
const contactUserId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
const contactAttributes = contact.attributes;
contactCache.revalidate({
id: contact.id,
@@ -156,6 +145,19 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
userId: contactUserId,
});
for (const attr of contactAttributes) {
contactAttributeCache.revalidate({
contactId: contact.id,
key: attr.attributeKey.key,
environmentId: contact.environmentId,
});
contactAttributeKeyCache.revalidate({
environmentId: contact.environmentId,
key: attr.attributeKey.key,
});
}
return contact;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -166,54 +168,6 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
}
};
export const getContactAttributes = reactCache((contactId: string) =>
cache(
async () => {
validateInputs([contactId, ZId]);
try {
const prismaAttributes = await prisma.contactAttribute.findMany({
where: {
contactId,
},
select: selectContactAttribute,
});
// return convertPrismaContactAttributes(prismaAttributes);
return prismaAttributes.reduce((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getContactAttributes-${contactId}`],
{
tags: [contactAttributeCache.tag.byContactId(contactId)],
}
)()
);
export const getContactAttributeKeys = reactCache(
(environmentId: string): Promise<TContactAttributeKey[]> =>
cache(
async () => {
return await prisma.contactAttributeKey.findMany({
where: { environmentId },
});
},
[`getContactAttributeKeys-${environmentId}`],
{
tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const createContactsFromCSV = async (
csvData: Record<string, string>[],
environmentId: string,

View File

@@ -1,7 +1,8 @@
import { contactCache } from "@/lib/cache/contact";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { UploadContactsCSVButton } from "@/modules/ee/contacts/components/upload-contacts-button";
import { getContactAttributeKeys, getContacts } from "@/modules/ee/contacts/lib/contacts";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContacts } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";

View File

@@ -1,6 +1,6 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contacts";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";

View File

@@ -90,6 +90,7 @@ const fetchLicenseForE2ETesting = async (): Promise<{
whitelabel: true,
removeBranding: true,
ai: true,
saml: true,
},
lastChecked: currentTime,
};
@@ -156,6 +157,7 @@ export const getEnterpriseLicense = async (): Promise<{
removeBranding: false,
contacts: false,
ai: false,
saml: false,
},
lastChecked: new Date(),
};
@@ -361,7 +363,7 @@ export const getIsTwoFactorAuthEnabled = async (): Promise<boolean> => {
return licenseFeatures.twoFactorAuth;
};
export const getIsSSOEnabled = async (): Promise<boolean> => {
export const getisSsoEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? previousResult.features.sso : false;
@@ -371,6 +373,21 @@ export const getIsSSOEnabled = async (): Promise<boolean> => {
return licenseFeatures.sso;
};
export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features
? previousResult.features.sso && previousResult.features.saml
: false;
}
if (IS_FORMBRICKS_CLOUD) {
return false;
}
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.sso && licenseFeatures.saml;
};
export const getIsOrganizationAIReady = async (billingPlan: Organization["billing"]["plan"]) => {
if (!IS_AI_CONFIGURED) return false;
if (E2E_TESTING) {

View File

@@ -12,6 +12,7 @@ const ZEnterpriseLicenseFeatures = z.object({
removeBranding: z.boolean(),
twoFactorAuth: z.boolean(),
sso: z.boolean(),
saml: z.boolean(),
ai: z.boolean(),
});

View File

@@ -0,0 +1,21 @@
"use server";
import { actionClient } from "@/lib/utils/action-client";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
import { SAML_PRODUCT, SAML_TENANT } from "@formbricks/lib/constants";
export const doesSamlConnectionExistAction = actionClient.action(async () => {
const jacksonInstance = await jackson();
if (!jacksonInstance) {
return false;
}
const { connectionController } = jacksonInstance;
const connection = await connectionController.getConnections({
product: SAML_PRODUCT,
tenant: SAML_TENANT,
});
return connection.length === 1;
});

View File

@@ -8,7 +8,7 @@ import { useCallback, useEffect } from "react";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
interface AzureButtonProps {
inviteUrl?: string | null;
inviteUrl?: string;
directRedirect?: boolean;
lastUsed?: boolean;
}

View File

@@ -7,7 +7,7 @@ import { signIn } from "next-auth/react";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
interface GithubButtonProps {
inviteUrl?: string | null;
inviteUrl?: string;
lastUsed?: boolean;
}

View File

@@ -7,7 +7,7 @@ import { signIn } from "next-auth/react";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
interface GoogleButtonProps {
inviteUrl?: string | null;
inviteUrl?: string;
lastUsed?: boolean;
}

View File

@@ -7,7 +7,7 @@ import { useCallback, useEffect } from "react";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
interface OpenIdButtonProps {
inviteUrl?: string | null;
inviteUrl?: string;
lastUsed?: boolean;
directRedirect?: boolean;
text?: string;

View File

@@ -0,0 +1,61 @@
"use client";
import { doesSamlConnectionExistAction } from "@/modules/ee/sso/actions";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { LockIcon } from "lucide-react";
import { signIn } from "next-auth/react";
import { useState } from "react";
import toast from "react-hot-toast";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
interface SamlButtonProps {
inviteUrl?: string;
lastUsed?: boolean;
samlTenant: string;
samlProduct: string;
}
export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct }: SamlButtonProps) => {
const { t } = useTranslate();
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Saml");
}
setIsLoading(true);
const doesSamlConnectionExist = await doesSamlConnectionExistAction();
if (!doesSamlConnectionExist?.data) {
toast.error(t("auth.saml_connection_error"));
setIsLoading(false);
return;
}
signIn(
"saml",
{
redirect: true,
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
},
{
tenant: samlTenant,
product: samlProduct,
}
);
};
return (
<Button
type="button"
onClick={handleLogin}
variant="secondary"
className="relative w-full justify-center"
loading={isLoading}>
{t("auth.continue_with_saml")}
<LockIcon />
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
</Button>
);
};

View File

@@ -1,10 +1,13 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { useEffect, useState } from "react";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
import { AzureButton } from "./azure-button";
import { GithubButton } from "./github-button";
import { GoogleButton } from "./google-button";
import { OpenIdButton } from "./open-id-button";
import { SamlButton } from "./saml-button";
interface SSOOptionsProps {
googleOAuthEnabled: boolean;
@@ -13,6 +16,9 @@ interface SSOOptionsProps {
oidcOAuthEnabled: boolean;
oidcDisplayName?: string;
callbackUrl: string;
samlSsoEnabled: boolean;
samlTenant: string;
samlProduct: string;
}
export const SSOOptions = ({
@@ -22,16 +28,42 @@ export const SSOOptions = ({
oidcOAuthEnabled,
oidcDisplayName,
callbackUrl,
samlSsoEnabled,
samlTenant,
samlProduct,
}: SSOOptionsProps) => {
const { t } = useTranslate();
const [lastLoggedInWith, setLastLoggedInWith] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setLastLoggedInWith(localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS) || "");
}
}, []);
return (
<div className="space-y-2">
{googleOAuthEnabled && <GoogleButton inviteUrl={callbackUrl} />}
{githubOAuthEnabled && <GithubButton inviteUrl={callbackUrl} />}
{azureOAuthEnabled && <AzureButton inviteUrl={callbackUrl} />}
{googleOAuthEnabled && (
<GoogleButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Google"} />
)}
{githubOAuthEnabled && (
<GithubButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Github"} />
)}
{azureOAuthEnabled && <AzureButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Azure"} />}
{oidcOAuthEnabled && (
<OpenIdButton inviteUrl={callbackUrl} text={t("auth.continue_with_oidc", { oidcDisplayName })} />
<OpenIdButton
inviteUrl={callbackUrl}
lastUsed={lastLoggedInWith === "OpenID"}
text={t("auth.continue_with_oidc", { oidcDisplayName })}
/>
)}
{samlSsoEnabled && (
<SamlButton
inviteUrl={callbackUrl}
lastUsed={lastLoggedInWith === "Saml"}
samlTenant={samlTenant}
samlProduct={samlProduct}
/>
)}
</div>
);

View File

@@ -15,6 +15,7 @@ import {
OIDC_DISPLAY_NAME,
OIDC_ISSUER,
OIDC_SIGNING_ALGORITHM,
WEBAPP_URL,
} from "@formbricks/lib/constants";
export const getSSOProviders = () => [
@@ -54,6 +55,36 @@ export const getSSOProviders = () => [
};
},
},
{
id: "saml",
name: "BoxyHQ SAML",
type: "oauth" as const,
version: "2.0",
checks: ["pkce" as const, "state" as const],
authorization: {
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
params: {
scope: "",
response_type: "code",
provider: "saml",
},
},
token: `${WEBAPP_URL}/api/auth/saml/token`,
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
profile(profile) {
return {
id: profile.id,
email: profile.email,
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
image: null,
};
},
options: {
clientId: "dummy",
clientSecret: "dummy",
},
allowDangerousEmailAccountLinking: true,
},
];
export type { IdentityProvider };

View File

@@ -1,6 +1,7 @@
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { createUser } from "@/modules/auth/lib/user";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import type { IdentityProvider } from "@prisma/client";
import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
@@ -12,12 +13,25 @@ import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
export const handleSSOCallback = async ({ user, account }: { user: TUser; account: Account }) => {
const isSsoEnabled = await getisSsoEnabled();
if (!isSsoEnabled) {
return false;
}
if (!user.email || account.type !== "oauth") {
return false;
}
let provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
if (provider === "saml") {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) {
return false;
}
}
if (account.provider) {
const provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
// check if accounts for this provider / account Id already exists
const existingUserWithAccount = await prisma.user.findFirst({
include: {

View File

@@ -1,5 +1,5 @@
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
import { getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next";
import {
@@ -14,6 +14,9 @@ import {
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
SAML_OAUTH_ENABLED,
SAML_PRODUCT,
SAML_TENANT,
TERMS_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
@@ -26,7 +29,11 @@ export const metadata: Metadata = {
export const SignupPage = async () => {
const locale = await findMatchingLocale();
const isSSOEnabled = await getIsSSOEnabled();
const [isSsoEnabled, isSamlSsoEnabled] = await Promise.all([getisSsoEnabled(), getIsSamlSsoEnabled()]);
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
const t = await getTranslate();
return (
<div className="flex flex-col items-center">
@@ -47,8 +54,11 @@ export const SignupPage = async () => {
userLocale={locale}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isSsoEnabled={isSsoEnabled}
samlSsoEnabled={samlSsoEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
/>
</div>
);

View File

@@ -1,5 +1,11 @@
"use client";
import {
getCXQuestionTypes,
getQuestionDefaults,
getQuestionTypes,
universalQuestionPresets,
} from "@/modules/survey/lib/questions";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { Project } from "@prisma/client";
@@ -8,12 +14,6 @@ import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import {
getCXQuestionTypes,
getQuestionDefaults,
getQuestionTypes,
universalQuestionPresets,
} from "@formbricks/lib/utils/questions";
interface AddQuestionButtonProps {
addQuestion: (question: any) => void;

View File

@@ -1,5 +1,11 @@
"use client";
import {
getCXQuestionNameMap,
getQuestionDefaults,
getQuestionIconMap,
getQuestionNameMap,
} from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import {
@@ -17,12 +23,6 @@ import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import {
getCXQuestionNameMap,
getQuestionDefaults,
getQuestionIconMap,
getQuestionNameMap,
} from "@formbricks/lib/utils/questions";
import {
TSurvey,
TSurveyEndScreenCard,

View File

@@ -2,6 +2,7 @@
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import {
Select,
SelectContent,
@@ -13,7 +14,6 @@ import { useTranslate } from "@tolgee/react";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getQuestionIconMap } from "@formbricks/lib/utils/questions";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorProps {

View File

@@ -18,6 +18,7 @@ import { PictureSelectionForm } from "@/modules/survey/editor/components/picture
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useSortable } from "@dnd-kit/sortable";
@@ -29,7 +30,6 @@ import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import {
TI18nString,

View File

@@ -205,6 +205,10 @@ export const ResponseOptionsCard = ({
}
};
const handleHideBackButtonToggle = () => {
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
};
useEffect(() => {
if (!!localSurvey.surveyClosedMessage) {
setSurveyClosedMessage({
@@ -515,6 +519,13 @@ export const ResponseOptionsCard = ({
</AdvancedOptionToggle>
</>
)}
<AdvancedOptionToggle
htmlId="hideBackButton"
isChecked={localSurvey.isBackButtonHidden}
onToggle={handleHideBackButtonToggle}
title={t("environments.surveys.edit.hide_back_button")}
description={t("environments.surveys.edit.hide_back_button_description")}
/>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -29,18 +29,21 @@ export const SurveyPlacementCard = ({
const { projectOverwrites } = localSurvey ?? {};
const { placement, clickOutsideClose, darkOverlay } = projectOverwrites ?? {};
const setProjectOverwrites = (projectOverwrites: TSurveyProjectOverwrites) => {
const setProjectOverwrites = (projectOverwrites: TSurveyProjectOverwrites | null) => {
setLocalSurvey({ ...localSurvey, projectOverwrites: projectOverwrites });
};
const togglePlacement = () => {
if (setProjectOverwrites) {
setProjectOverwrites({
...projectOverwrites,
placement: !!placement ? null : "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
});
if (!!placement) {
setProjectOverwrites(null);
} else {
setProjectOverwrites({
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
});
}
}
};

View File

@@ -479,6 +479,14 @@ export const getLogicRules = (t: TFnType) => {
label: t("environments.surveys.edit.does_not_end_with"),
value: ZSurveyLogicConditionsOperator.Enum.doesNotEndWith,
},
{
label: t("environments.surveys.edit.is_set"),
value: ZSurveyLogicConditionsOperator.Enum.isSet,
},
{
label: t("environments.surveys.edit.is_not_set"),
value: ZSurveyLogicConditionsOperator.Enum.isNotSet,
},
],
},
};

View File

@@ -1,4 +1,5 @@
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { ActionClass } from "@prisma/client";
import { TFnType } from "@tolgee/react";
@@ -7,7 +8,6 @@ import { HTMLInputTypeAttribute } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
import { getQuestionTypes } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { InvalidInputError } from "@formbricks/types/errors";
import {
@@ -216,6 +216,8 @@ export const getMatchValueProps = (
"isPartiallySubmitted",
"isSkipped",
"isSubmitted",
"isSet",
"isNotSet",
].includes(condition.operator)
) {
return { show: false, options: [] };

View File

@@ -1,5 +1,5 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contacts";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";

View File

@@ -6,6 +6,7 @@ import {
ZCreateSurveyFollowUpFormSchema,
} from "@/modules/survey/editor/types/survey-follow-up";
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Editor } from "@/modules/ui/components/editor";
@@ -38,7 +39,6 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getQuestionIconMap } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";

View File

@@ -20,6 +20,7 @@ import {
StarIcon,
} from "lucide-react";
import type { JSX } from "react";
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
import {
TSurveyQuestionTypeEnum as QuestionId,
TSurveyAddressQuestion,
@@ -38,7 +39,6 @@ import {
TSurveyRankingQuestion,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys/types";
import { replaceQuestionPresetPlaceholders } from "./templates";
export type TQuestion = {
id: string;

View File

@@ -41,6 +41,7 @@ export const selectSurvey = {
pin: true,
resultShareKey: true,
showLanguageSwitch: true,
isBackButtonHidden: true,
languages: {
select: {
default: true,

View File

@@ -6,19 +6,11 @@ import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
import { Project, Response } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TResponseData, TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TResponseData, TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
let setIsError = (_: boolean) => {};
let setIsResponseSendingFinished = (_: boolean) => {};
let setQuestionId = (_: string) => {};
let setResponseData = (_: TResponseData) => {};
@@ -57,15 +49,10 @@ export const LinkSurvey = ({
locale,
isPreview,
}: LinkSurveyProps) => {
const { t } = useTranslate();
const responseId = singleUseResponse?.id;
const searchParams = useSearchParams();
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
const sourceParam = searchParams.get("source");
const suId = searchParams.get("suId");
const defaultLanguageCode = survey.languages.find((surveyLanguage) => {
return surveyLanguage.default;
})?.language.code;
const startAt = searchParams.get("startAt");
const isStartAtValid = useMemo(() => {
@@ -84,32 +71,8 @@ export const LinkSurvey = ({
return isValid;
}, [survey, startAt]);
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
let surveyState = useMemo(() => {
return new SurveyState(survey.id, singleUseId, responseId);
}, [survey.id, singleUseId, responseId]);
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
const responseQueue = useMemo(
() =>
new ResponseQueue(
{
apiHost: webAppUrl,
environmentId: survey.environmentId,
retryAttempts: 2,
onResponseSendingFailed: () => {
setIsError(true);
},
onResponseSendingFinished: () => {
// when response of current question is processed successfully
setIsResponseSendingFinished(true);
},
},
surveyState
),
[webAppUrl, survey.environmentId, surveyState]
);
const [autoFocus, setAutofocus] = useState(false);
const hasFinishedSingleUseResponse = useMemo(() => {
if (singleUseResponse?.finished) {
@@ -148,11 +111,7 @@ export const LinkSurvey = ({
}
}, [survey.isVerifyEmailEnabled, verifiedEmail]);
useEffect(() => {
responseQueue.updateSurveyState(surveyState);
}, [responseQueue, surveyState]);
if (!surveyState.isResponseFinished() && hasFinishedSingleUseResponse) {
if (hasFinishedSingleUseResponse) {
return <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
}
@@ -211,81 +170,14 @@ export const LinkSurvey = ({
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
<SurveyInline
apiHost={!isPreview ? webAppUrl : undefined}
environmentId={!isPreview ? survey.environmentId : undefined}
survey={survey}
styling={determineStyling()}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
shouldResetQuestionId={false}
getSetIsError={(f: (value: boolean) => void) => {
setIsError = f;
}}
getSetIsResponseSendingFinished={
!isPreview
? (f: (value: boolean) => void) => {
setIsResponseSendingFinished = f;
}
: undefined
}
onRetry={() => {
setIsError(false);
void responseQueue.processQueue();
}}
onDisplay={() => {
if (!isPreview) {
void (async () => {
const api = new FormbricksAPI({
apiHost: webAppUrl,
environmentId: survey.environmentId,
});
const res = await api.client.display.create({
surveyId: survey.id,
});
if (!res.ok) {
throw new Error(t("s.could_not_create_display"));
}
const { id } = res.data;
surveyState.updateDisplayId(id);
responseQueue.updateSurveyState(surveyState);
})();
}
}}
onResponse={(responseUpdate: TResponseUpdate) => {
!isPreview &&
responseQueue.add({
data: {
...responseUpdate.data,
...hiddenFieldsRecord,
...getVerifiedEmail,
},
ttc: responseUpdate.ttc,
finished: responseUpdate.finished,
endingId: responseUpdate.endingId,
language:
responseUpdate.language === "default" && defaultLanguageCode
? defaultLanguageCode
: responseUpdate.language,
meta: {
url: window.location.href,
source: typeof sourceParam === "string" ? sourceParam : "",
},
variables: responseUpdate.variables,
displayId: surveyState.displayId,
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
});
}}
onFileUpload={async (file: TJsFileUploadParams["file"], params: TUploadFileConfig) => {
const api = new FormbricksAPI({
apiHost: webAppUrl,
environmentId: survey.environmentId,
});
const uploadedUrl = await api.client.storage.uploadFile(file, params);
return uploadedUrl;
}}
onFileUpload={isPreview ? async (file) => `https://formbricks.com/${file.name}` : undefined}
// eslint-disable-next-line jsx-a11y/no-autofocus -- need it as focus behaviour is different in normal surveys and survey preview
autoFocus={autoFocus}
prefillResponseData={prefillValue}
@@ -299,7 +191,13 @@ export const LinkSurvey = ({
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
fullSizeCards={isEmbed}
hiddenFieldsRecord={hiddenFieldsRecord}
hiddenFieldsRecord={{
...hiddenFieldsRecord,
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={responseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
/>
</LinkSurveyWrapper>
);

View File

@@ -41,4 +41,5 @@ export const getMinimalSurvey = (t: TFnType): TSurvey => ({
isSingleResponsePerEmailEnabled: false,
variables: [],
followUps: [],
isBackButtonHidden: false,
});

View File

@@ -1,5 +1,6 @@
"use client";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import { Switch } from "@/modules/ui/components/switch";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -8,7 +9,6 @@ import { useTranslate } from "@tolgee/react";
import { capitalize } from "lodash";
import { GripVertical } from "lucide-react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getQuestionIconMap } from "@formbricks/lib/utils/questions";
import { TSurvey } from "@formbricks/types/surveys/types";
interface DataTableSettingsModalItemProps<T> {

View File

@@ -229,7 +229,7 @@ export const InputCombobox = ({
className="min-w-0 rounded-none border-0 border-r border-slate-300 bg-white focus:border-slate-400"
{...inputProps}
id={`${id}-input`}
value={localValue as string | number}
value={localValue ?? undefined}
onChange={onInputChange}
/>
)}

View File

@@ -1,20 +1,21 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbricks-surveys";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
const createContainerId = () => `formbricks-survey-container`;
declare global {
interface Window {
formbricksSurveys: {
renderSurveyInline: (props: SurveyInlineProps) => void;
renderSurveyModal: (props: SurveyModalProps) => void;
renderSurveyInline: (props: SurveyContainerProps) => void;
renderSurveyModal: (props: SurveyContainerProps) => void;
renderSurvey: (props: SurveyContainerProps) => void;
};
}
}
export const SurveyInline = (props: Omit<SurveyInlineProps, "containerId">) => {
export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) => {
const containerId = useMemo(() => createContainerId(), []);
const renderInline = useCallback(
() => window.formbricksSurveys.renderSurveyInline({ ...props, containerId }),
() => window.formbricksSurveys.renderSurvey({ ...props, containerId, mode: "inline" }),
[containerId, props]
);
const [isScriptLoaded, setIsScriptLoaded] = useState(false);

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "3.2.0",
"version": "3.3.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -15,6 +15,7 @@
},
"dependencies": {
"@ai-sdk/azure": "1.1.9",
"@boxyhq/saml-jackson": "1.37.1",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",

View File

@@ -29,6 +29,7 @@ test.describe("JS Package Test", async () => {
let server: http.Server;
let environmentId: string;
test.setTimeout(3 * 60 * 1000);
test.beforeAll(async () => {
// Create a simple HTTP server
server = http.createServer((_, res) => {
@@ -124,6 +125,7 @@ test.describe("JS Package Test", async () => {
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
await page.waitForLoadState("networkidle");
await page.waitForTimeout(5000);
// Validate displays and response
await page.goto("/");
@@ -131,7 +133,7 @@ test.describe("JS Package Test", async () => {
await page.getByRole("link", { name: "product Market Fit (Superhuman)" }).click();
await page.waitForSelector("text=Responses");
await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000);
await page.waitForTimeout(5000);
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
expect(impressionsCount).toEqual("Impressions\n\n1");

View File

@@ -3,9 +3,18 @@
import { TolgeeProvider, TolgeeStaticData } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { branchName } from "../../../branch.json";
import { TolgeeBase } from "./shared";
// Try to import branch.json, but handle the case where it doesn't exist
let branchName: string | undefined;
try {
const branch = require("../../../branch.json");
branchName = branch.branchName;
} catch (e) {
// File doesn't exist in production, so we'll use undefined
branchName = undefined;
}
type Props = {
language: string;
staticData: TolgeeStaticData;
@@ -13,7 +22,7 @@ type Props = {
};
const tolgee = TolgeeBase().init({
tagNewKeys: [`draft: ${branchName}`],
tagNewKeys: branchName ? [`draft:${branchName}`] : [],
});
export const TolgeeNextProvider = ({ language, staticData, children }: Props) => {
@@ -25,6 +34,7 @@ export const TolgeeNextProvider = ({ language, staticData, children }: Props) =>
router.refresh();
});
return () => unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tolgee, router]);
return (

View File

@@ -1,13 +1,22 @@
import { createServerInstance } from "@tolgee/react/server";
import { branchName } from "../../../branch.json";
import { getLocale } from "./language";
import { TolgeeBase } from "./shared";
// Try to import branch.json, but handle the case where it doesn't exist
let branchName: string | undefined;
try {
const branch = require("../../../branch.json");
branchName = branch.branchName;
} catch (e) {
// File doesn't exist in production, so we'll use undefined
branchName = undefined;
}
export const { getTolgee, getTranslate, T } = createServerInstance({
getLocale: getLocale,
createTolgee: async (language) => {
return TolgeeBase().init({
tagNewKeys: [`draft: ${branchName}`],
tagNewKeys: branchName ? [`draft:${branchName}`] : [],
observerOptions: {
fullKeyEncode: true,
},

View File

@@ -1 +0,0 @@
{ "branchName": "main" }

View File

@@ -50,9 +50,6 @@ x-environment: &environment
############################################## OPTIONAL (APP CONFIGURATION) ##############################################
# Set the below value if you have and want to use a custom URL for the links created by the Link Shortener
# SHORT_URL_BASE:
# Set the below to 0 to enable Email Verification for new signups (will required Email Configuration)
EMAIL_VERIFICATION_DISABLED: 1
@@ -114,6 +111,9 @@ x-environment: &environment
# OIDC_DISPLAY_NAME:
# OIDC_SIGNING_ALGORITHM:
# Set the below to SAML Provider if you want to enable SAML
# SAML_DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks-saml?sslmode=disable"
########################################## OPTIONAL (THIRD PARTY INTEGRATIONS) ###########################################
# Oauth credentials for Notion Integration
@@ -188,9 +188,11 @@ services:
- 3000:3000
volumes:
- uploads:/home/nextjs/apps/web/uploads/
- ./saml-connection:/home/nextjs/apps/web/saml-connection
<<: *environment
volumes:
postgres:
driver: local
uploads:
driver: local

View File

@@ -6952,7 +6952,7 @@
},
"/api/v1/management/surveys/{surveyId}/singleUseIds": {
"get": {
"description": "Generates multiple single use survey links for a survey based on its id. Count of links can be controlled using the limit query param(min: 1, max: 5000, default: 10).",
"description": "Generates multiple single use survey links for a survey based on its id.",
"parameters": [
{
"example": "{{apiKey}}",
@@ -6970,6 +6970,21 @@
"schema": {
"type": "string"
}
},
{
"description": "Number of links to generate.",
"example": 10,
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 10,
"exclusiveMaximum": false,
"exclusiveMinimum": false,
"maximum": 5000,
"minimum": 1,
"type": "integer"
}
}
],
"responses": {

View File

@@ -0,0 +1,14 @@
---
title: "API v2 Reference (Draft)"
icon: "code-compare"
---
Formbricks offers two types of APIs: the **Public Client API** and the **Management API**. Each API serves a different purpose, has *different* authentication requirements, and provides access to different data and settings.
### API Key Setup
Checkout the [API Key Setup](/api-reference/rest-api) to access the Management APIs with an API Key.
If youve forked the collection and are running it, update the `apiKey` and `environmentId` in the collection variables with your values. We also provide post-run scripts to help auto-assign variables when running scripts.
Need more help? Visit our [Website](https://formbricks.com/) or join our [Discord](https://formbricks.com/discord)!

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
---
title: "How to setup SAML with Identity Providers"
title: "Setup SAML with Identity Providers"
description: "This guide explains the settings you need to use to configure SAML with your Identity Provider. Once configured, obtain an XML metadata file and use it to configure SAML in Formbricks."
---
### SAML Registration with Identity Providers
This guide explains the settings you need to use to configure SAML with your Identity Provider. Once configured, obtain an XML metadata file and upload it on your Formbricks instance.
This guide explains the settings you need to use to configure SAML with your Identity Provider. Once configured, obtain an XML metadata file and use it to configure SAML in Formbricks.
> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below.
**Assertion consumer service URL / Single Sign-On URL / Destination URL:** https://app.formbricks.com/api/auth/saml/callback
**Assertion consumer service URL / Single Sign-On URL / Destination URL:** [https://app.formbricks.com/api/auth/saml/callback](https://app.formbricks.com/api/auth/saml/callback)
**Entity ID / Identifier / Audience URI / Audience Restriction:** [https://saml.formbricks.com](https://saml.formbricks.com)
@@ -22,20 +23,25 @@ This guide explains the settings you need to use to configure SAML with your Ide
**Mapping Attributes / Attribute Statements:**
- http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier -> id
- http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress -> email
- http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname -> firstName
- http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname -> lastName
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier) -> id
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress) -> email
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname) -> firstName
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname) -> lastName
### SAML With Okta
<Steps>
<Step title="Create an application with your OIDC provider">
<Step title="Create an application with your SAML provider">
For example, in Okta, once you create an account, you can click on Applications on the sidebar menu:
<img src="/images/development/guides/auth-and-provision/okta/okta-applications.webp" />
</Step>
<Step title="Click on Create App Integration">
<img src="/images/development/guides/auth-and-provision/okta/create-app-integration.webp" />
<img src="/images/development/guides/auth-and-provision/okta/create-app-integration.webp" />
</Step>
<Step title="Select SAML 2.0 in the modal form, and click Next">
<img src="/images/development/guides/auth-and-provision/okta/select-saml-2.0.webp" />
@@ -43,12 +49,14 @@ This guide explains the settings you need to use to configure SAML with your Ide
<Step title="Fill the general settings as shown and click Next">
<img src="/images/development/guides/auth-and-provision/okta/general-settings.webp" />
</Step>
<Step title="Enter the SAML Integration Settings as shown and click Next">
- **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally)
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com`
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
</Step>
<Step title="Fill the fields mapping as shown and click Next">
<img src="/images/development/guides/auth-and-provision/okta/fields-mapping.webp" />
</Step>
<Step title="Enter the SAML Integration Settings as shown and click Next">
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
</Step>
<Step title="Check the internal app checkbox and click Finish">
<img src="/images/development/guides/auth-and-provision/okta/internal-app.webp" />
</Step>
@@ -64,13 +72,13 @@ This guide explains the settings you need to use to configure SAML with your Ide
<Step title="Select the users you want to assign the app to and click Assign">
<img src="/images/development/guides/auth-and-provision/okta/select-users.webp" />
</Step>
<Step title="Head over to the Sign On tab and scroll to the bottom to get the metadata, click on the Actions button">
<img src="/images/development/guides/auth-and-provision/okta/actions-button.webp" />
<Step title="Head over to the Sign On tab and click on the 'view SAML setup instructions' button">
<img src="/images/development/guides/auth-and-provision/okta/view-saml-instructions.webp" />
</Step>
<Step title="Click on View IdP metadata">
<img src="/images/development/guides/auth-and-provision/okta/view-idp-metadata.webp" />
<Step title="Scroll to the bottom and copy the IDP metadata">
<img src="/images/development/guides/auth-and-provision/okta/idp-metadata.webp" />
</Step>
<Step title="Copy the metadata and paste it in the Formbricks SAML configuration"></Step>
<Step title="Copy the IDP metadata and paste it in the `connection.xml` file in the `formbricks/saml-connection` directory" />
</Steps>
That's it. Now when you try to login with SSO, your application on Okta will handle the authentication.
That's it. Now when you try to login with SSO, your application on Okta will handle the authentication.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -1,71 +1,76 @@
<svg width="198" height="263" viewBox="0 0 198 263" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 183.149H79.2V222.749C79.2 244.619 61.4705 262.349 39.6 262.349C17.7295 262.349 0 244.619 0 222.749V183.149Z" fill="url(#paint0_linear_301_72)"/>
<path d="M0 91.5747H158.4C180.27 91.5747 198 109.304 198 131.175C198 153.045 180.27 170.775 158.4 170.775H0V91.5747Z" fill="url(#paint1_linear_301_72)"/>
<path d="M0 64.9181C0 29.0648 29.0648 0 64.918 0H158.4C180.27 0 198 17.7295 198 39.6C198 61.4705 180.27 79.2 158.4 79.2H0V64.9181Z" fill="url(#paint2_linear_301_72)"/>
<mask id="mask0_301_72" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="198" height="263">
<path d="M0 183.151H79.2V222.751C79.2 244.621 61.4705 262.351 39.6 262.351C17.7295 262.351 0 244.621 0 222.751V183.151Z" fill="url(#paint3_linear_301_72)"/>
<path d="M0 91.5762H158.4C180.27 91.5762 198 109.306 198 131.176C198 153.047 180.27 170.776 158.4 170.776H0V91.5762Z" fill="url(#paint4_linear_301_72)"/>
<path d="M0 64.9181C0 29.0648 29.0648 0 64.918 0H158.4C180.27 0 198 17.7295 198 39.6C198 61.4705 180.27 79.2 158.4 79.2H0V64.9181Z" fill="url(#paint5_linear_301_72)"/>
<svg width="220" height="220" viewBox="0 0 220 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="220" height="220" fill="url(#paint0_radial_740_2)"/>
<path d="M50.2842 141.289H98.1312V165.213C98.1312 178.425 87.4203 189.136 74.2077 189.136C60.9951 189.136 50.2842 178.425 50.2842 165.213V141.289Z" fill="url(#paint1_linear_740_2)"/>
<path d="M50.2842 85.9663H145.978C159.191 85.9663 169.902 96.6772 169.902 109.89C169.902 123.102 159.191 133.813 145.978 133.813H50.2842V85.9663Z" fill="url(#paint2_linear_740_2)"/>
<path d="M50.2842 69.8617C50.2842 48.2017 67.8431 30.6428 89.5031 30.6428H145.978C159.191 30.6428 169.902 41.3538 169.902 54.5664C169.902 67.779 159.191 78.4899 145.978 78.4899H50.2842V69.8617Z" fill="url(#paint3_linear_740_2)"/>
<mask id="mask0_740_2" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="50" y="30" width="120" height="160">
<path d="M50.2842 141.291H98.1312V165.214C98.1312 178.427 87.4203 189.138 74.2077 189.138C60.9951 189.138 50.2842 178.427 50.2842 165.214V141.291Z" fill="url(#paint4_linear_740_2)"/>
<path d="M50.2842 85.9675H145.978C159.191 85.9675 169.902 96.6785 169.902 109.891C169.902 123.104 159.191 133.815 145.978 133.815H50.2842V85.9675Z" fill="url(#paint5_linear_740_2)"/>
<path d="M50.2842 69.862C50.2842 48.202 67.8431 30.6431 89.5031 30.6431H145.978C159.191 30.6431 169.902 41.354 169.902 54.5666C169.902 67.7792 159.191 78.4901 145.978 78.4901H50.2842V69.862Z" fill="url(#paint6_linear_740_2)"/>
</mask>
<g mask="url(#mask0_301_72)">
<g filter="url(#filter0_d_301_72)">
<mask id="mask1_301_72" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="198" height="263">
<path d="M0 183.151H79.2V222.751C79.2 244.621 61.4705 262.351 39.6 262.351C17.7295 262.351 0 244.621 0 222.751V183.151Z" fill="black" fill-opacity="0.1"/>
<path d="M0 64.9181C0 29.0648 29.0648 0 64.918 0H158.4C180.27 0 198 17.7295 198 39.6C198 61.4705 180.27 79.2 158.4 79.2H0V64.9181Z" fill="black" fill-opacity="0.1"/>
<path d="M0 91.5762H158.4C180.27 91.5762 198 109.306 198 131.176C198 153.047 180.27 170.776 158.4 170.776H0V91.5762Z" fill="black" fill-opacity="0.1"/>
<g mask="url(#mask0_740_2)">
<g filter="url(#filter0_d_740_2)">
<mask id="mask1_740_2" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="50" y="30" width="120" height="160">
<path d="M50.2842 141.291H98.1312V165.214C98.1312 178.427 87.4203 189.138 74.2077 189.138C60.9951 189.138 50.2842 178.427 50.2842 165.214V141.291Z" fill="black" fill-opacity="0.1"/>
<path d="M50.2842 69.8622C50.2842 48.2022 67.8431 30.6433 89.5031 30.6433H145.978C159.191 30.6433 169.902 41.3542 169.902 54.5668C169.902 67.7794 159.191 78.4904 145.978 78.4904H50.2842V69.8622Z" fill="black" fill-opacity="0.1"/>
<path d="M50.2842 85.9678H145.978C159.191 85.9678 169.902 96.6787 169.902 109.891C169.902 123.104 159.191 133.815 145.978 133.815H50.2842V85.9678Z" fill="black" fill-opacity="0.1"/>
</mask>
<g mask="url(#mask1_301_72)">
<path d="M4.1553 -68.2164C35.1799 -98.4956 113.85 -68.2164 113.85 -68.2164H4.1553C-3.4647 -60.7794 -8.21048 -49.6893 -8.21048 -33.6001C-8.21048 47.996 80.1781 77.6677 80.1781 134.538C80.1781 190.209 -4.52334 224.555 -8.09431 300.203H113.85C113.85 300.203 -8.21048 384.271 -8.21048 305.148C-8.21048 303.48 -8.17121 301.832 -8.09431 300.203H-61.875L-51.3525 -68.2164H4.1553Z" fill="black" fill-opacity="0.1"/>
<g mask="url(#mask1_740_2)">
<path d="M52.7947 -10.5675C71.5376 -28.8601 119.065 -10.5675 119.065 -10.5675H52.7947C48.1912 -6.07459 45.3241 0.625288 45.3241 10.3453C45.3241 59.6399 98.7223 77.5654 98.7223 111.922C98.7223 145.555 47.5517 166.304 45.3943 212.005H119.065C119.065 212.005 45.3241 262.794 45.3241 214.993C45.3241 213.985 45.3479 212.99 45.3943 212.005H12.9038L19.2607 -10.5675H52.7947Z" fill="black" fill-opacity="0.1"/>
</g>
</g>
<g filter="url(#filter1_f_301_72)">
<circle cx="-24.75" cy="227.7" r="74.25" fill="#00C4B8"/>
<g filter="url(#filter1_f_740_2)">
<circle cx="35.3322" cy="168.204" r="44.8566" fill="#00C4B8"/>
</g>
<g filter="url(#filter2_f_301_72)">
<circle cx="-24.75" cy="39.6006" r="74.25" fill="#00C4B8"/>
<g filter="url(#filter2_f_740_2)">
<circle cx="35.3322" cy="54.5671" r="44.8566" fill="#00C4B8"/>
</g>
</g>
<defs>
<filter id="filter0_d_301_72" x="-6.4918" y="-38.9508" width="191.752" height="340.253" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<filter id="filter0_d_740_2" x="46.3623" y="7.11196" width="115.843" height="205.557" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="32.459"/>
<feGaussianBlur stdDeviation="19.4754"/>
<feOffset dx="19.6095"/>
<feGaussianBlur stdDeviation="11.7657"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_301_72"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_301_72" result="shape"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_740_2"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_740_2" result="shape"/>
</filter>
<filter id="filter1_f_301_72" x="-163.918" y="88.5317" width="278.336" height="278.336" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<filter id="filter1_f_740_2" x="-48.7433" y="84.1283" width="168.151" height="168.151" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="32.459" result="effect1_foregroundBlur_301_72"/>
<feGaussianBlur stdDeviation="19.6095" result="effect1_foregroundBlur_740_2"/>
</filter>
<filter id="filter2_f_301_72" x="-163.918" y="-99.5675" width="278.336" height="278.336" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<filter id="filter2_f_740_2" x="-48.7433" y="-29.5085" width="168.151" height="168.151" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="32.459" result="effect1_foregroundBlur_301_72"/>
<feGaussianBlur stdDeviation="19.6095" result="effect1_foregroundBlur_740_2"/>
</filter>
<linearGradient id="paint0_linear_301_72" x1="79.5444" y1="221.314" x2="-0.00681719" y2="221.636" gradientUnits="userSpaceOnUse">
<radialGradient id="paint0_radial_740_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(110 110) rotate(90) scale(110)">
<stop stop-color="#334155"/>
<stop offset="1" stop-color="#0F172A"/>
</radialGradient>
<linearGradient id="paint1_linear_740_2" x1="98.3393" y1="164.346" x2="50.2801" y2="164.54" gradientUnits="userSpaceOnUse">
<stop offset="1" stop-color="#00C4B8"/>
</linearGradient>
<linearGradient id="paint1_linear_301_72" x1="198.861" y1="129.74" x2="2.49336e-08" y2="131.749" gradientUnits="userSpaceOnUse">
<linearGradient id="paint2_linear_740_2" x1="170.422" y1="109.023" x2="50.2842" y2="110.237" gradientUnits="userSpaceOnUse">
<stop stop-color="#00DDD0"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint2_linear_301_72" x1="198.861" y1="38.1652" x2="2.49336e-08" y2="40.1739" gradientUnits="userSpaceOnUse">
<linearGradient id="paint3_linear_740_2" x1="170.422" y1="53.6996" x2="50.2842" y2="54.9131" gradientUnits="userSpaceOnUse">
<stop stop-color="#00DDD0"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint3_linear_301_72" x1="79.5444" y1="221.316" x2="-0.00681719" y2="221.638" gradientUnits="userSpaceOnUse">
<linearGradient id="paint4_linear_740_2" x1="98.3393" y1="164.347" x2="50.2801" y2="164.541" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint4_linear_301_72" x1="198.861" y1="129.741" x2="2.49336e-08" y2="131.75" gradientUnits="userSpaceOnUse">
<linearGradient id="paint5_linear_740_2" x1="170.422" y1="109.024" x2="50.2842" y2="110.238" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint5_linear_301_72" x1="198.861" y1="38.1652" x2="2.49336e-08" y2="40.1739" gradientUnits="userSpaceOnUse">
<linearGradient id="paint6_linear_740_2" x1="170.422" y1="53.6998" x2="50.2842" y2="54.9133" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

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