Compare commits

...

31 Commits

Author SHA1 Message Date
StepSecurity Bot
93f60db26c [StepSecurity] Apply security best practices
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
2025-03-06 10:15:24 +00:00
Dhruwang Jariwala
0e0d3780d3 fix: removed completed surveys from survey list in integrations (#4838)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-06 09:10:51 +00:00
Peter Pesti-Varga
38ff01aedc feat: Android & iOS SDK (#4871) 2025-03-06 09:43:49 +01:00
Dhruwang Jariwala
cdf687ad80 fix: delete webhook button visibility (#4862) 2025-03-06 07:40:00 +00:00
github-actions[bot]
a399fc7f80 chore: bump version to v3.3.1 (#4873)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-03-06 08:37:59 +01:00
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
436 changed files with 13154 additions and 2355 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=

96
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
- package-ecosystem: npm
directory: /apps/demo-react-native
schedule:
interval: daily
- package-ecosystem: npm
directory: /apps/demo
schedule:
interval: daily
- package-ecosystem: npm
directory: /apps/storybook
schedule:
interval: daily
- package-ecosystem: docker
directory: /apps/web
schedule:
interval: daily
- package-ecosystem: npm
directory: /apps/web
schedule:
interval: daily
- package-ecosystem: npm
directory: /
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/api
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/config-eslint
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/config-prettier
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/config-typescript
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/database
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/js-core
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/js
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/lib
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/react-native
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/surveys
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/types
schedule:
interval: daily
- package-ecosystem: npm
directory: /packages/vite-plugins
schedule:
interval: daily

View File

@@ -5,6 +5,9 @@ on:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
@@ -15,8 +18,13 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@v5
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -12,7 +12,12 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/dangerous-git-checkout
- name: Build & Cache Web Binaries

View File

@@ -11,19 +11,24 @@ jobs:
name: Run Chromatic
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@latest
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

78
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: ["main"]
pull_request:
# The branches below must be a subset of the branches above
branches: ["main"]
schedule:
- cron: "0 0 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["javascript", "typescript"]
# CodeQL supports [ $supported-codeql-languages ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
category: "/language:${{matrix.language}}"

View File

@@ -18,6 +18,11 @@ jobs:
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |

View File

@@ -7,6 +7,9 @@ on:
schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
- cron: "0 8 * * 1"
permissions:
contents: read
jobs:
cron-weeklySummary:
permissions:
@@ -16,6 +19,11 @@ jobs:
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |

27
.github/workflows/dependency-review.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0

View File

@@ -43,16 +43,21 @@ jobs:
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v3
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
@@ -112,7 +117,7 @@ jobs:
- name: Azure login
if: env.AZURE_ENABLED == 'true'
uses: azure/login@v2
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -130,7 +135,7 @@ jobs:
run: |
pnpm test:e2e
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
if: always()
with:
name: playwright-report

View File

@@ -4,6 +4,9 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
labeler:
name: Pull Request Labeler
@@ -12,7 +15,12 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481

View File

@@ -12,6 +12,11 @@ jobs:
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout

View File

@@ -50,6 +50,11 @@ jobs:
checks: write
statuses: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
run: exit 1

View File

@@ -18,6 +18,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout

View File

@@ -26,23 +26,28 @@ jobs:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout Repo
uses: actions/checkout@v2
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2
with:
node-version: 18.x
- name: Install pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd # v2.2.4
- name: Install Dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9
with:
# This expects you to have a script called release which does a build for your packages and calls changeset publish
publish: pnpm release

View File

@@ -17,6 +17,9 @@ env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -28,23 +31,28 @@ jobs:
id-token: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Set up Depot CLI
uses: depot/setup-action@v1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# 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
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 # v3.0.0
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -54,7 +62,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5 # v5.0.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -62,7 +70,7 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@v1
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}

View File

@@ -20,6 +20,9 @@ env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -31,23 +34,28 @@ jobs:
id-token: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Set up Depot CLI
uses: depot/setup-action@v1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# 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
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 # v3.0.0
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -57,7 +65,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5 # v5.0.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -65,7 +73,7 @@ jobs:
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@v1
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}

View File

@@ -5,6 +5,9 @@ on:
tags:
- "v*"
permissions:
contents: read
jobs:
release-image-on-dockerhub:
name: Release on Dockerhub
@@ -16,17 +19,22 @@ jobs:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout Repo
uses: actions/checkout@v2
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- name: Log in to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
- name: Get Release Tag
id: extract_release_tag
@@ -36,7 +44,7 @@ jobs:
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1
with:
context: .
file: ./apps/web/Dockerfile

View File

@@ -34,6 +34,11 @@ jobs:
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
@@ -71,6 +76,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif

View File

@@ -16,7 +16,12 @@ jobs:
name: PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -35,7 +40,7 @@ jobs:
revert
ossgg
- uses: marocchino/sticky-pull-request-comment@v2
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
# When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null)
@@ -54,7 +59,7 @@ jobs:
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: pr-title-lint-error
message: |

View File

@@ -6,6 +6,7 @@ on:
- main
pull_request:
types: [opened, synchronize, reopened]
merge_group:
permissions:
contents: read
jobs:
@@ -13,7 +14,12 @@ jobs:
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis

View File

@@ -1,6 +1,9 @@
name: Tests
on:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Unit Tests
@@ -10,16 +13,21 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -12,11 +12,16 @@ jobs:
check-missing-translations:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 18

View File

@@ -3,7 +3,8 @@ permissions:
contents: read
on:
push:
pull_request:
types: [closed]
branches:
- main
@@ -11,13 +12,38 @@ jobs:
tag-production-keys:
name: Tag Production Keys
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 18 # Ensure compatibility with your project
@@ -26,13 +52,12 @@ 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 unused production keys as Deprecated
run: |
@@ -43,11 +68,10 @@ jobs:
- name: Tag unused draft:current-branch keys as Deprecated
run: |
BRANCH_NAME=${GITHUB_REF##*/}
npx tolgee tag \
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag "draft:${BRANCH_NAME}" \
--tag deprecated --untag "draft:${BRANCH_NAME}"
--filter-not-extracted --filter-tag "draft:${SOURCE_BRANCH}" \
--tag deprecated --untag "draft:${SOURCE_BRANCH}"
- name: Sync with backup
run: |
@@ -58,7 +82,7 @@ jobs:
--yes
- name: Upload backup as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: tolgee-backup-${{ github.sha }}
path: ./tolgee-backup

View File

@@ -17,7 +17,12 @@ jobs:
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- uses: actions/first-interaction@v1
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-

43
.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,39 +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
# tolgee
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

@@ -3,15 +3,19 @@
# Load environment variables from .env files
if [ -f .env ]; then
export $(cat .env | grep -v '#' | xargs)
set -a
. .env
set +a
fi
pnpm lint-staged
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
# 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

18
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,18 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.16.3
hooks:
- id: gitleaks
- repo: https://github.com/jumanjihouse/pre-commit-hooks
rev: 3.0.0
hooks:
- id: shellcheck
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.38.0
hooks:
- id: eslint
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace

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

@@ -1,4 +1,4 @@
FROM node:22-alpine3.20 AS base
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
#
## step 1: Prune monorepo
@@ -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

@@ -1,4 +1,5 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -15,7 +16,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";

View File

@@ -1,4 +1,5 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -19,7 +20,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";

View File

@@ -0,0 +1,48 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache(
async (environmentId: string): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveys-${environmentId}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

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,3 +1,4 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -21,7 +22,6 @@ import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getNotionDatabases } from "@formbricks/lib/notion/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";

View File

@@ -1,3 +1,4 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -14,7 +15,6 @@ import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";

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: {

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

@@ -21,14 +21,14 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { deleteWebhookAction, testEndpointAction, updateWebhookAction } from "../actions";
import { TWebhookInput } from "../types/webhooks";
interface ActionSettingsTabProps {
interface WebhookSettingsTabProps {
webhook: Webhook;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
}
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: ActionSettingsTabProps) => {
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => {
const { t } = useTranslate();
const router = useRouter();
const { register, handleSubmit } = useForm({
@@ -219,7 +219,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: Ac
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
{webhook.source === "user" && !isReadOnly && (
{!isReadOnly && (
<Button
type="button"
variant="destructive"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>
);

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