mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 21:32:02 -06:00
Compare commits
31 Commits
473-weekly
...
stepsecuri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f60db26c | ||
|
|
0e0d3780d3 | ||
|
|
38ff01aedc | ||
|
|
cdf687ad80 | ||
|
|
a399fc7f80 | ||
|
|
c54a48e70b | ||
|
|
884b6f12ae | ||
|
|
5cae0febc9 | ||
|
|
0e898db710 | ||
|
|
40d54d60d4 | ||
|
|
269e026381 | ||
|
|
8245f2f6af | ||
|
|
8c07e8b1a8 | ||
|
|
e94b0845a2 | ||
|
|
4acc85bd12 | ||
|
|
ffa534d5eb | ||
|
|
fccf0f1e39 | ||
|
|
a5d80d1f02 | ||
|
|
803a73afb6 | ||
|
|
1eb8049d04 | ||
|
|
f9ed0c487f | ||
|
|
fa7d33351f | ||
|
|
e3084760b8 | ||
|
|
8e5addad5c | ||
|
|
6e741018e5 | ||
|
|
98c7c78421 | ||
|
|
16c588138c | ||
|
|
1373863af5 | ||
|
|
75315ea2c5 | ||
|
|
9f6fb8a387 | ||
|
|
b84d3d5806 |
@@ -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
|
||||
@@ -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
96
.github/dependabot.yml
vendored
Normal 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
|
||||
10
.github/workflows/apply-issue-labels-to-pr.yml
vendored
10
.github/workflows/apply-issue-labels-to-pr.yml
vendored
@@ -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: |
|
||||
|
||||
7
.github/workflows/build-web.yml
vendored
7
.github/workflows/build-web.yml
vendored
@@ -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
|
||||
|
||||
13
.github/workflows/chromatic.yml
vendored
13
.github/workflows/chromatic.yml
vendored
@@ -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
78
.github/workflows/codeql.yml
vendored
Normal 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}}"
|
||||
@@ -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: |
|
||||
|
||||
8
.github/workflows/cron-weeklySummary.yml
vendored
8
.github/workflows/cron-weeklySummary.yml
vendored
@@ -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
27
.github/workflows/dependency-review.yml
vendored
Normal 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
|
||||
15
.github/workflows/e2e.yml
vendored
15
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/labeler.yml
vendored
10
.github/workflows/labeler.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/lint.yml
vendored
5
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
|
||||
5
.github/workflows/pr.yml
vendored
5
.github/workflows/pr.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/prepare-release.yml
vendored
5
.github/workflows/prepare-release.yml
vendored
@@ -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
|
||||
|
||||
13
.github/workflows/release-changesets.yml
vendored
13
.github/workflows/release-changesets.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
20
.github/workflows/release-docker-github.yml
vendored
20
.github/workflows/release-docker-github.yml
vendored
@@ -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 }}
|
||||
|
||||
16
.github/workflows/release-docker.yml
vendored
16
.github/workflows/release-docker.yml
vendored
@@ -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
|
||||
|
||||
7
.github/workflows/scorecard.yml
vendored
7
.github/workflows/scorecard.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/semantic-pull-requests.yml
vendored
11
.github/workflows/semantic-pull-requests.yml
vendored
@@ -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: |
|
||||
|
||||
8
.github/workflows/sonarqube.yml
vendored
8
.github/workflows/sonarqube.yml
vendored
@@ -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
|
||||
|
||||
|
||||
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
44
.github/workflows/tolgee.yml
vendored
44
.github/workflows/tolgee.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
43
.gitignore
vendored
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||
@@ -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
18
.pre-commit-config.yaml
Normal 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
|
||||
@@ -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
3
apps/web/.gitignore
vendored
@@ -48,3 +48,6 @@ uploads/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
# SAML Preloaded Connections
|
||||
saml-connection/
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
3
apps/web/app/api/auth/saml/authorize/route.ts
Normal file
3
apps/web/app/api/auth/saml/authorize/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/authorize/route";
|
||||
|
||||
export { GET };
|
||||
3
apps/web/app/api/auth/saml/callback/route.ts
Normal file
3
apps/web/app/api/auth/saml/callback/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/callback/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/token/route.ts
Normal file
3
apps/web/app/api/auth/saml/token/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/token/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/userinfo/route.ts
Normal file
3
apps/web/app/api/auth/saml/userinfo/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/userinfo/route";
|
||||
|
||||
export { GET };
|
||||
@@ -61,6 +61,7 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
displayLimit: true,
|
||||
displayOption: true,
|
||||
hiddenFields: true,
|
||||
isBackButtonHidden: true,
|
||||
triggers: {
|
||||
select: {
|
||||
actionClass: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
apps/web/app/health/route.ts
Normal file
3
apps/web/app/health/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function GET() {
|
||||
return Response.json({ status: "ok" });
|
||||
}
|
||||
@@ -7064,5 +7064,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
} as TSurvey;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
33
apps/web/modules/ee/auth/saml/api/authorize/route.ts
Normal file
33
apps/web/modules/ee/auth/saml/api/authorize/route.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
32
apps/web/modules/ee/auth/saml/api/callback/route.ts
Normal file
32
apps/web/modules/ee/auth/saml/api/callback/route.ts
Normal 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);
|
||||
};
|
||||
18
apps/web/modules/ee/auth/saml/api/token/route.ts
Normal file
18
apps/web/modules/ee/auth/saml/api/token/route.ts
Normal 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);
|
||||
};
|
||||
87
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.test.ts
Normal file
87
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
14
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.ts
Normal file
14
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.ts
Normal 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();
|
||||
};
|
||||
16
apps/web/modules/ee/auth/saml/api/userinfo/route.ts
Normal file
16
apps/web/modules/ee/auth/saml/api/userinfo/route.ts
Normal 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);
|
||||
};
|
||||
43
apps/web/modules/ee/auth/saml/lib/jackson.ts
Normal file
43
apps/web/modules/ee/auth/saml/lib/jackson.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
73
apps/web/modules/ee/auth/saml/lib/preload-connection.ts
Normal file
73
apps/web/modules/ee/auth/saml/lib/preload-connection.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
110
apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts
Normal file
110
apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
20
apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts
Normal file
20
apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
92
apps/web/modules/ee/contacts/lib/contact-attributes.ts
Normal file
92
apps/web/modules/ee/contacts/lib/contact-attributes.ts
Normal 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),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ const ZEnterpriseLicenseFeatures = z.object({
|
||||
removeBranding: z.boolean(),
|
||||
twoFactorAuth: z.boolean(),
|
||||
sso: z.boolean(),
|
||||
saml: z.boolean(),
|
||||
ai: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
21
apps/web/modules/ee/sso/actions.ts
Normal file
21
apps/web/modules/ee/sso/actions.ts
Normal 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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
apps/web/modules/ee/sso/components/saml-button.tsx
Normal file
61
apps/web/modules/ee/sso/components/saml-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -41,6 +41,7 @@ export const selectSurvey = {
|
||||
pin: true,
|
||||
resultShareKey: true,
|
||||
showLanguageSwitch: true,
|
||||
isBackButtonHidden: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user