Compare commits

..

43 Commits

Author SHA1 Message Date
Piyush Gupta
c54a48e70b docs: adds email followup docs (#4858)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-03-04 16:02:54 +00:00
Johannes
884b6f12ae docs: update API intro and key management docs (#4841) 2025-03-04 15:36:23 +00:00
Piyush Gupta
5cae0febc9 fix: variables initialization in logic editor preview (#4819)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-04 14:58:13 +00:00
Dhruwang Jariwala
0e898db710 chore: Remove lib dependency from survey package (#4767)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-04 14:58:00 +00:00
Johannes
40d54d60d4 docs: Release test environment docs (#4842) 2025-03-04 14:57:41 +00:00
Matti Nannt
269e026381 fix: sonarqube action not running in merge queue (#4861) 2025-03-04 15:57:26 +01:00
Matti Nannt
8245f2f6af chore: update sonar-config to properly scan apps/web (#4844) 2025-03-03 18:38:16 +01:00
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
Matti Nannt
7d4409e2b4 docs: add monitoring page to self-hosting (#4774) 2025-02-20 00:55:19 +01:00
269 changed files with 7377 additions and 2424 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

@@ -31,16 +31,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Depot CLI
uses: depot/setup-action@v1
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.5.0
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
@@ -63,8 +62,10 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
uses: depot/build-push-action@v1
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64

View File

@@ -34,16 +34,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Depot CLI
uses: depot/setup-action@v1
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.5.0
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
@@ -66,8 +65,10 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
uses: depot/build-push-action@v1
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64

View File

@@ -6,6 +6,7 @@ on:
- main
pull_request:
types: [opened, synchronize, reopened]
merge_group:
permissions:
contents: read
jobs:

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

@@ -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

@@ -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

@@ -138,7 +138,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
) : (
<FileInput
id="logo-input"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(files: string[]) => {
setLogoUrl(files[0]);
@@ -151,7 +151,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
<Input
ref={fileInputRef}
type="file"
accept="image/jpeg, image/png, image/webp"
accept="image/jpeg, image/png, image/webp, image/heic"
className="hidden"
disabled={isReadOnly}
onChange={handleFileChange}

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

@@ -308,7 +308,7 @@ export const QuestionFormInput = ({
{showImageUploader && id === "headline" && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={localSurvey.environmentId}
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
if (url) {

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

@@ -114,7 +114,7 @@ export const EditWelcomeCard = ({
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="welcome-card-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(url: string[]) => {
updateSurvey({ fileUrl: url[0] });

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

@@ -16,7 +16,7 @@ export const UploadImageSurveyBg = ({
<div className="flex w-full items-center justify-center">
<FileInput
id="survey-bg-file-input"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(url: string[]) => {
if (url.length > 0) {

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

@@ -133,7 +133,7 @@ export const PictureSelectionForm = ({
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="choices-file-input"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={handleFileInputChanges}
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}

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

@@ -68,7 +68,7 @@ export const FileInput = ({
toast.error(t("common.only_one_file_allowed"));
}
const allowedFiles = getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
const allowedFiles = await getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
if (allowedFiles.length === 0) {
return;
@@ -137,7 +137,7 @@ export const FileInput = ({
};
const handleUploadMore = async (files: File[]) => {
const allowedFiles = getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
const allowedFiles = await getAllowedFiles(files, allowedFileExtensions, maxSizeInMB);
if (allowedFiles.length === 0) {
return;
}

View File

@@ -0,0 +1,29 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { z } from "zod";
const ZConvertHeicToJpegInput = z.object({
file: z.instanceof(File),
});
export const convertHeicToJpegAction = authenticatedActionClient
.schema(ZConvertHeicToJpegInput)
.action(async ({ parsedInput }) => {
if (!parsedInput.file || !parsedInput.file.name.endsWith(".heic")) return parsedInput.file;
const convert = (await import("heic-convert")).default;
const arrayBuffer = await parsedInput.file.arrayBuffer();
const nodeBuffer = Buffer.from(arrayBuffer) as unknown as ArrayBufferLike;
const convertedBuffer = await convert({
buffer: nodeBuffer,
format: "JPEG",
quality: 0.9,
});
return new File([convertedBuffer], parsedInput.file.name.replace(/\.heic$/, ".jpg"), {
type: "image/jpeg",
});
});

View File

@@ -2,6 +2,7 @@
import { toast } from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { convertHeicToJpegAction } from "./actions";
export const uploadFile = async (
file: File | Blob,
@@ -15,8 +16,6 @@ export const uploadFile = async (
const fileBuffer = await file.arrayBuffer();
// check the file size
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
@@ -74,7 +73,6 @@ export const uploadFile = async (
});
}
// Add the actual file to be uploaded
formData.append("file", file);
const uploadResponse = await fetch(signedUrl, {
@@ -96,34 +94,63 @@ export const uploadFile = async (
}
};
export const getAllowedFiles = (
const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => {
if (maxSizeInMB && fileSizeInMB > maxSizeInMB) {
return true;
}
return false;
};
export const getAllowedFiles = async (
files: File[],
allowedFileExtensions: string[],
maxSizeInMB?: number
): File[] => {
): Promise<File[]> => {
const sizeExceedFiles: string[] = [];
const unsupportedExtensionFiles: string[] = [];
const convertedFiles: File[] = [];
const allowedFiles = files.filter((file) => {
for (const file of files) {
if (!file || !file.type) {
return false;
continue;
}
const extension = file.name.split(".").pop();
const fileSizeInMB = file.size / 1000000; // Kb -> Mb
const extension = file.name.split(".").pop()?.toLowerCase();
const fileSizeInMB = file.size / 1000000;
if (!allowedFileExtensions.includes(extension as TAllowedFileExtension)) {
unsupportedExtensionFiles.push(file.name);
return false; // Exclude file if extension not allowed
} else if (maxSizeInMB && fileSizeInMB > maxSizeInMB) {
sizeExceedFiles.push(file.name);
return false; // Exclude files larger than the maximum size
continue;
}
return true;
});
if (isFileSizeExceed(fileSizeInMB, maxSizeInMB)) {
sizeExceedFiles.push(file.name);
continue;
}
if (extension === "heic") {
const convertedFileResponse = await convertHeicToJpegAction({ file });
if (!convertedFileResponse?.data) {
unsupportedExtensionFiles.push(file.name);
continue;
} else {
const convertedFileSizeInMB = convertedFileResponse.data.size / 1000000;
if (isFileSizeExceed(convertedFileSizeInMB, maxSizeInMB)) {
sizeExceedFiles.push(file.name);
continue;
}
const convertedFile = new File([convertedFileResponse.data], file.name.replace(/\.heic$/, ".jpg"), {
type: "image/jpeg",
});
convertedFiles.push(convertedFile);
continue;
}
}
convertedFiles.push(file);
}
// Constructing toast messages based on the issues found
let toastMessage = "";
if (sizeExceedFiles.length > 0) {
toastMessage += `Files exceeding size limit (${maxSizeInMB} MB): ${sizeExceedFiles.join(", ")}. `;
@@ -134,7 +161,7 @@ export const getAllowedFiles = (
if (toastMessage) {
toast.error(toastMessage);
}
return allowedFiles;
return convertedFiles;
};
export const checkForYoutubePrivacyMode = (url: string): boolean => {

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",
@@ -83,6 +84,7 @@
"file-loader": "6.2.0",
"framer-motion": "11.15.0",
"googleapis": "144.0.0",
"heic-convert": "2.1.0",
"https-proxy-agent": "7.0.6",
"jiti": "2.4.1",
"jsonwebtoken": "9.0.2",
@@ -129,6 +131,7 @@
"@formbricks/eslint-config": "workspace:*",
"@neshca/cache-handler": "1.9.0",
"@types/bcryptjs": "2.4.6",
"@types/heic-convert": "2.1.0",
"@types/lodash": "4.17.13",
"@types/markdown-it": "14.1.2",
"@types/nodemailer": "6.4.17",

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

@@ -0,0 +1,44 @@
---
title: "Generate API Key"
icon: "key"
description: "Here is how you can generate an API key which gives you full access to the Formbricks Management API. Keep it safe!"
---
<Note>
As of now, API keys are located in the Project Configuration page. We are moving them to the Organization Settings page in the upcoming release. For you, nothing will change.
</Note>
## Generate API key
<Steps>
<Step title="Navigate to 'Configuration'">
Go to the Configuration page of your project:
![Configuration Page](/images/api-reference/config.webp)
</Step>
<Step title="Access 'API Keys' tab">
Click on the **API Keys** tab.
</Step>
<Step title="Generate key">
Decide if you want to generate a key for the development or production environment. If you want to switch environemnts you can do so in the top right corner. Click on the corresponding button.
</Step>
<Step title="Add a label to your key">
Add a label to your key to help you identify it.
![Label key](/images/api-reference/label.webp)
</Step>
<Step title="Copy your key">
Copy the API key and save it in a secure location. You won't be able to see it again.
<Note>
Store API key safely! Anyone who has your API key has full control over your
account. For security reasons, you cannot view the API key again.
</Note>
</Step>
</Steps>
## Delete API key
- On **Configuration** > **API Keys** page, find the key you wish to revoke and select “Delete”.
- Your API key will stop working immediately.

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": {
@@ -7710,7 +7725,7 @@
}
},
"summary": "Health Check",
"tags": ["default"]
"tags": ["Health"]
}
}
},

View File

@@ -5,9 +5,8 @@ description: "
Formbricks provides two APIs: the Public Client API for frontend survey interactions and the Management API for backend management tasks."
---
<Info>
View our [API Documentation](/api-reference) in more than 30 frameworks and languages.
</Info>
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.
## Public Client API
@@ -17,7 +16,7 @@ We currently have the following Client API methods exposed and below is their do
- [Displays API](/api-reference/client-api->-display/create-display) - Mark a survey as displayed or link a display to a response for a person.
- [People API](/api-reference/client-api->-people/create-person) - Create & Update a Person (e.g., attributes, email, userId, etc.)
- [Contacts API](/api-reference/client-api->-contacts/update-contact-attributes) - Update contact attributes.
- [Responses API](/api-reference/client-api->-response/create-response) - Create & Update a Response for a Survey.
@@ -41,88 +40,7 @@ We currently have the following Management API methods exposed and below is thei
- [Webhook API](/api-reference/management-api->-webhook/get-all-webhooks) - List, Create, and Delete Webhooks
## How to Generate an API key
API requests require a personal API key for authorization. This API key gives you the same rights as if you were logged in at Formbricks UI - **keep it private!**
- Go to your settings on [Formbricks UI](https://app.formbricks.com).
- Go to page “API keys”
![API Keys](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738097810/image_jvhqsd.jpg)
- Create a key for the development or production environment.
- Copy the key immediately. You wont be able to see it again.
![API Key Label](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738098072/image_zjkvok.jpg)
<Note>
**Store API key safely! Anyone who has your API key has full control over your
account. For security reasons, you cannot view the API key again.**
</Note>
## Test your API Key
Hit the below request to verify that you are authenticated with your API Key and the server is responding.
### Get My Profile
Get the project details and environment type of your account.
### Mandatory Headers
| Name | x-Api-Key |
| --------------- | ------------------------ |
| **Type** | string |
| **Description** | Your Formbricks API key. |
### Request
```bash cURL
GET - /api/v1/me
curl --location \
'https://app.formbricks.com/api/v1/me' \
--header \
'x-api-key: <your-api-key>'
```
### Response
<CodeGroup>
```bash 200 (Success)
{
"id": "cll2m30r70004mx0huqkitgqv",
"createdAt": "2023-08-08T18:04:59.922Z",
"updatedAt": "2023-08-08T18:04:59.922Z",
"type": "production",
"project": {
"id": "cll2m30r60003mx0hnemjfckr",
"name": "My Project"
},
"appSetupCompleted": false,
"websiteSetupCompleted": false,
}
```
```bash 401 (Not Authenticated)
Not authenticated
```
</CodeGroup>
### Delete a personal API key
- Go to settings on [app.formbricks.com](https://app.formbricks.com/).
- Go to the page “API keys”.
- Find the key you wish to revoke and select “Delete”.
- Your API key will stop working immediately.
---

View File

@@ -0,0 +1,50 @@
---
title: "Test API Key"
icon: "message-check"
description: "Here is how you can test your API key to make sure it is working."
---
To test if your API key is working, you can use the following request:
### Mandatory Headers
| Name | x-Api-Key |
| --------------- | ------------------------ |
| **Type** | string |
| **Description** | Your Formbricks API key. |
### Request
```bash cURL
GET - /api/v1/me
curl --location \
'https://app.formbricks.com/api/v1/me' \
--header \
'x-api-key: <your-api-key>'
```
### Response
<CodeGroup>
```bash 200 (Success)
{
"id": "cll2m30r70004mx0huqkitgqv",
"createdAt": "2023-08-08T18:04:59.922Z",
"updatedAt": "2023-08-08T18:04:59.922Z",
"type": "production",
"project": {
"id": "cll2m30r60003mx0hnemjfckr",
"name": "My Project"
},
"appSetupCompleted": false,
"websiteSetupCompleted": false,
}
```
```bash 401 (Not Authenticated)
Not authenticated
```
</CodeGroup>

View File

@@ -1,5 +1,5 @@
---
title: "API v1.0.0"
title: "API v2 Reference (Draft)"
icon: "code-compare"
---

File diff suppressed because it is too large Load Diff

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