mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-23 02:45:21 -05:00
Compare commits
301 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc69fa3b43 | |||
| 47b60eccd5 | |||
| b2a95d4cee | |||
| 64b4e18c5a | |||
| ae9c1e499a | |||
| daae319c7a | |||
| 5b70c99eb3 | |||
| 10c09f00a8 | |||
| 5f4f133dcb | |||
| 037b005d48 | |||
| ddd2d5e983 | |||
| 6777b284b3 | |||
| c6282632e0 | |||
| f84c409bc4 | |||
| 98b475a2a4 | |||
| c48474b943 | |||
| 3c0d1e3fd7 | |||
| 1f7a496967 | |||
| 99e378ae2e | |||
| c6e39c3103 | |||
| 19472ca9d3 | |||
| 43feff0009 | |||
| 507cec6958 | |||
| d874b65be9 | |||
| c159af1a26 | |||
| 3ce2ef6cf4 | |||
| baae6335c9 | |||
| 8e443db1f6 | |||
| 18cd9afc2c | |||
| 680e1e1593 | |||
| 04bfdeef69 | |||
| 8a1db7b6aa | |||
| 1d22fe2da6 | |||
| d8a119712c | |||
| 50089eeca4 | |||
| d8fe832f5f | |||
| 1c904e2243 | |||
| 4dbecc2d58 | |||
| 72f4e93432 | |||
| 9007502804 | |||
| d84589452c | |||
| 43aaed3923 | |||
| 550bfc6a6c | |||
| 2c22b00ec6 | |||
| d64fb546d3 | |||
| f4ca7c46ef | |||
| c252d8c4c9 | |||
| 2bec3b040d | |||
| 3c49b33dad | |||
| 0f2f3d337e | |||
| 4d1df795ad | |||
| 3ce2998d0d | |||
| b9a6520e10 | |||
| 55bb9a525e | |||
| 11055f812e | |||
| ecf3aacca3 | |||
| a0f3d2a651 | |||
| 16bbd7a447 | |||
| a276aa6d34 | |||
| d192fbf839 | |||
| c5d52df9b7 | |||
| 550e859a2d | |||
| 6fb9cf28b1 | |||
| 8c47cdba73 | |||
| e6b6f5e6d3 | |||
| 6218153351 | |||
| 9ef4be270b | |||
| ed42df34c4 | |||
| 8c8ff8e396 | |||
| 72cf2d6a50 | |||
| c5d629ef25 | |||
| 71cb8bdff5 | |||
| 850fb8acc3 | |||
| 94c9e8fcf1 | |||
| 49a8c8c686 | |||
| 2832831db1 | |||
| b5e6567194 | |||
| 86d3f2fae1 | |||
| 62d09f6a8f | |||
| 74dd778630 | |||
| 7ac99c0840 | |||
| dde0f8d32c | |||
| bcd3c91075 | |||
| f376c620ab | |||
| 4865a78338 | |||
| a7c8e1acf9 | |||
| e5a097e56e | |||
| 1ddde9cac7 | |||
| 59f5cdfb4b | |||
| 8431eaf9f6 | |||
| f228e8e06a | |||
| 5e6ab81cb1 | |||
| 1417a5a654 | |||
| f8ae92b3be | |||
| 1bc3f79f30 | |||
| 7151dd5234 | |||
| 086315ce33 | |||
| e01b4311ca | |||
| dd757394af | |||
| 507f80f9b0 | |||
| 8562232280 | |||
| 1234e6685a | |||
| 40a5e8ea6a | |||
| 319a76a70d | |||
| 2abf8e1d8c | |||
| a985dc698b | |||
| 7b59a6300e | |||
| bf8b4079fd | |||
| 5704bfbc03 | |||
| 0920ccf2c3 | |||
| db0c9e7c55 | |||
| ef87d899b9 | |||
| ea92ef9fce | |||
| 778fc2acf1 | |||
| 2ffef36c89 | |||
| 1d6bda74df | |||
| 12ff0b7c0e | |||
| fa1079bac1 | |||
| 1403f0bb01 | |||
| c79553633f | |||
| f16fb3b62f | |||
| 7dfc7f4825 | |||
| 1ecc9f1722 | |||
| 7d1c02b54b | |||
| f2c452d7f9 | |||
| afcfbb7a3a | |||
| 7f8c9dcbb8 | |||
| 3998e4da31 | |||
| 48086faffc | |||
| 38a0d7c810 | |||
| b17bb88daa | |||
| f59e9f13ec | |||
| 5169dec510 | |||
| 0df16f6f0c | |||
| 8442dedf9c | |||
| 22c27c5ebb | |||
| 6638dceb04 | |||
| 8558121e46 | |||
| f1279d51e5 | |||
| 926706be9d | |||
| 85b456e619 | |||
| 3bac488a29 | |||
| fbe2a31133 | |||
| 79d618f77c | |||
| 89eb04f813 | |||
| 8a2b349329 | |||
| a862b739f7 | |||
| 4e5df85538 | |||
| 727b349086 | |||
| f75db6b1d0 | |||
| 7ffca53577 | |||
| 25614b23fc | |||
| 016e14d0f1 | |||
| be80db8418 | |||
| bcc3789ce8 | |||
| 5e76ebdfc1 | |||
| 150f256721 | |||
| da7971328c | |||
| a6cd56b196 | |||
| 7c81cf119e | |||
| 8d29b24352 | |||
| a1ae849496 | |||
| 4d0a686e89 | |||
| 364915e4c8 | |||
| ada2518d0c | |||
| 57d1c0ed99 | |||
| 817b299436 | |||
| c140dae872 | |||
| 6036a8c767 | |||
| bf592937f4 | |||
| 1cfadd968a | |||
| 21ed383a46 | |||
| 7ed7101ac1 | |||
| 7aa12a4f0c | |||
| 2e926936fb | |||
| 8edef8aede | |||
| 54fb202285 | |||
| c720a462a7 | |||
| a386451e6e | |||
| f0bf111e7b | |||
| 8a57a5b74b | |||
| 434cb1d0d0 | |||
| 8bde75a9ff | |||
| 6b880f29cb | |||
| 969c9834e5 | |||
| 5e33b7c9a4 | |||
| 230ea744fa | |||
| fae1fb8f96 | |||
| eac35daed9 | |||
| 45accc1edb | |||
| 02ebe8e9f8 | |||
| cae859e326 | |||
| 5352d563b6 | |||
| 711f2bfe67 | |||
| 6fcb5d39a2 | |||
| 1ed9859ee7 | |||
| cd72a0a78d | |||
| 2b09795787 | |||
| 2451acb9bd | |||
| 14dcded91b | |||
| 46062f91cd | |||
| ffd4478184 | |||
| 69da1862fa | |||
| c11d3241ab | |||
| 3fb09a1a26 | |||
| 6efa449c10 | |||
| 34b94689ca | |||
| 901fac7e08 | |||
| 739c662863 | |||
| 535974ff8a | |||
| a8b97abe9a | |||
| 28103604b4 | |||
| b5a7e15386 | |||
| fec4746d5d | |||
| 175323e7d9 | |||
| 6130737d51 | |||
| bf10a8d0b2 | |||
| 612f8dceb8 | |||
| 0303f16db4 | |||
| 07635b160e | |||
| 9cfcffdb5e | |||
| 02264ffc5f | |||
| 7dde3edd8d | |||
| 730ab6a609 | |||
| 4304a7efd6 | |||
| a89d598f8d | |||
| 6ff5af712f | |||
| 398ba79e7e | |||
| 4e75a57692 | |||
| 5127de9de0 | |||
| 2bf7788a1b | |||
| ee8122778b | |||
| 8aaa7ed9c0 | |||
| bc7c8c5715 | |||
| ab1ea7a5ce | |||
| 4f749355e0 | |||
| 18b60ddd35 | |||
| 87f1b01c7a | |||
| 851ea0deb2 | |||
| 9abbbfdd35 | |||
| 990c0eee31 | |||
| 07f16b8a43 | |||
| bf556b0608 | |||
| 8b0766a46e | |||
| 1f995d6e25 | |||
| 975a4d57f8 | |||
| 69bd576fc5 | |||
| a2e4a3bbd7 | |||
| 281f854332 | |||
| 24496774a5 | |||
| aeaf3215b4 | |||
| f4c5162590 | |||
| dedb7389f0 | |||
| 7aed1b84de | |||
| 9d2e988c59 | |||
| 31d2ea7444 | |||
| 3da7129413 | |||
| 75fbb23190 | |||
| d361c334d3 | |||
| a4d808b479 | |||
| 18ae1748d3 | |||
| 60f6ca9463 | |||
| 3404e0c494 | |||
| 83499ae552 | |||
| 2ac0c1eb07 | |||
| 54ede3015e | |||
| 1b4f05a062 | |||
| 197dbf5aa6 | |||
| aa27d242bb | |||
| 7ca52a7a93 | |||
| 4a48839d17 | |||
| 92bd9bdac7 | |||
| ad4b6f8b8c | |||
| 8de5079db3 | |||
| a60206dd44 | |||
| d66abdcdaf | |||
| 03fa41a911 | |||
| cab438e474 | |||
| a6dfe78c81 | |||
| e4d96f4379 | |||
| 581a66b4a9 | |||
| 5cf0c15812 | |||
| ebaa2d363c | |||
| 597ea40b75 | |||
| 3c39dcc2de | |||
| e8df1dbb35 | |||
| 84987ce557 | |||
| 784ed855d7 | |||
| 5a17d4144d | |||
| 65c9db86c6 | |||
| bc94d34d1e | |||
| 22be60a0ba | |||
| a384963863 | |||
| c067ae73bb | |||
| dc78a30cbe | |||
| 9c9ae8a3a2 | |||
| 29a08151aa | |||
| f42a8822a9 | |||
| a771ae189a | |||
| 029e069af6 | |||
| 81272b96e1 |
@@ -0,0 +1 @@
|
||||
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
|
||||
@@ -1 +0,0 @@
|
||||
../skills
|
||||
@@ -1 +0,0 @@
|
||||
../skills
|
||||
@@ -53,7 +53,7 @@ function {QuestionType}({
|
||||
}: {QuestionType}Props): React.JSX.Element {
|
||||
// Ensure value is always the correct type (handle undefined/null)
|
||||
const currentValue = value ?? {defaultValue};
|
||||
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
@@ -63,11 +63,11 @@ function {QuestionType}({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
/>
|
||||
|
||||
{/* Question-specific controls */}
|
||||
@@ -349,3 +349,4 @@ When creating a new question element, verify:
|
||||
- [ ] TypeScript types properly exported
|
||||
- [ ] Error message display included if applicable
|
||||
- [ ] Disabled state supported if applicable
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../skills
|
||||
+9
-23
@@ -63,23 +63,17 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
|
||||
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
||||
HUB_API_KEY=dev-api-key
|
||||
HUB_API_URL=http://localhost:8080
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disable
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
|
||||
# pinned default in the compose file; override here when testing a specific Hub release.
|
||||
# HUB_IMAGE_TAG=0.3.0
|
||||
# HUB_IMAGE_TAG=0.2.0
|
||||
|
||||
# Hub embeddings are optional. Set a provider and model to enable semantic search embeddings in
|
||||
# the Hub API and hub-worker. For provider-specific settings, see:
|
||||
# https://hub.formbricks.com/reference/environment-variables/#embeddings
|
||||
# Example with Google AI Studio:
|
||||
# EMBEDDING_PROVIDER=google
|
||||
# EMBEDDING_MODEL=gemini-embedding-001
|
||||
# EMBEDDING_PROVIDER_API_KEY=
|
||||
|
||||
####################
|
||||
# CUBE ANALYTICS #
|
||||
####################
|
||||
# Cube semantic-layer API. Required. The bundled Docker stack exposes Cube on port 4000.
|
||||
###########################
|
||||
# CUBE ANALYTICS (XM V5) #
|
||||
###########################
|
||||
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
|
||||
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
|
||||
# COMPOSE_PROFILES=xm
|
||||
CUBEJS_API_URL=http://localhost:4000
|
||||
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
|
||||
CUBEJS_API_SECRET=
|
||||
@@ -118,7 +112,7 @@ SMTP_PASSWORD=smtpPassword
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
|
||||
# S3 Storage is required for the file upload in serverless environments
|
||||
# S3 Storage is required for the file upload in serverless environments like Vercel
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=
|
||||
@@ -154,14 +148,6 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion SSO confirmation #
|
||||
###########################################
|
||||
|
||||
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
|
||||
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
|
||||
|
||||
|
||||
##########
|
||||
# Other #
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
name: Accessibility issue
|
||||
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
|
||||
type: bug
|
||||
labels: ["accessibility", "bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What part of Formbricks is affected and what's wrong?
|
||||
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Open a survey with multiple languages
|
||||
2. Press Tab repeatedly
|
||||
3. Focus never lands on the language switcher
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: wcag
|
||||
attributes:
|
||||
label: Related WCAG criterion (if known)
|
||||
placeholder: "e.g. 2.1.1 Keyboard"
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: Severity
|
||||
options:
|
||||
- "Critical — blocks a user from completing a core task"
|
||||
- "High — significant barrier with no easy workaround"
|
||||
- "Medium — barrier with a workaround"
|
||||
- "Low — minor friction"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: at
|
||||
attributes:
|
||||
label: Assistive technology
|
||||
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser and OS
|
||||
placeholder: "e.g. Firefox 138 on Windows 11"
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Your Environment
|
||||
options:
|
||||
- Formbricks Cloud (app.formbricks.com)
|
||||
- Self-hosted Formbricks
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: Other information (screenshots, recordings, axe output)
|
||||
@@ -20,12 +20,12 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Cache Build
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
uses: actions/cache@v3
|
||||
id: cache-build
|
||||
env:
|
||||
cache-name: prod-build
|
||||
@@ -43,7 +43,7 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
@@ -53,7 +53,7 @@ runs:
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
|
||||
@@ -147,10 +147,6 @@ jobs:
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-e REDIS_URL="redis://host.docker.internal:6379" \
|
||||
-e HUB_API_URL="http://localhost:4000" \
|
||||
-e HUB_API_KEY="build-time-placeholder" \
|
||||
-e CUBEJS_API_URL="http://localhost:4000" \
|
||||
-e CUBEJS_API_SECRET="build-time-placeholder" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
|
||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||
|
||||
+48
-37
@@ -57,7 +57,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
shell: bash
|
||||
|
||||
- name: Create .env
|
||||
@@ -81,48 +81,65 @@ jobs:
|
||||
echo "S3_REGION=us-east-1" >> .env
|
||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
|
||||
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
|
||||
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||
shell: bash
|
||||
|
||||
- name: Start RustFS Server
|
||||
- name: Install MinIO client (mc)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
|
||||
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
|
||||
MC_BIN="mc.${MC_VERSION}"
|
||||
MC_SUM="${MC_BIN}.sha256sum"
|
||||
|
||||
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
|
||||
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
|
||||
|
||||
sha256sum -c "${MC_SUM}"
|
||||
|
||||
chmod +x "${MC_BIN}"
|
||||
sudo mv "${MC_BIN}" /usr/local/bin/mc
|
||||
|
||||
- name: Start MinIO Server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start RustFS server in background
|
||||
# Start MinIO server in background
|
||||
docker run -d \
|
||||
--name rustfs-server \
|
||||
--name minio-server \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
-e RUSTFS_ACCESS_KEY=devrustfs \
|
||||
-e RUSTFS_SECRET_KEY=devrustfs123 \
|
||||
-e RUSTFS_ADDRESS=:9000 \
|
||||
-e RUSTFS_CONSOLE_ENABLE=true \
|
||||
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
|
||||
rustfs/rustfs:1.0.0-alpha.93 \
|
||||
/data
|
||||
-e MINIO_ROOT_USER=devminio \
|
||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||
server /data --console-address :9001
|
||||
|
||||
echo "RustFS server started"
|
||||
echo "MinIO server started"
|
||||
|
||||
- name: Bootstrap RustFS bucket and browser upload CORS
|
||||
- name: Wait for MinIO and create S3 bucket
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--entrypoint /bin/sh \
|
||||
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
|
||||
-e RUSTFS_ADMIN_USER=devrustfs \
|
||||
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
|
||||
-e RUSTFS_SERVICE_USER=devrustfs-service \
|
||||
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
|
||||
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
|
||||
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
|
||||
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
|
||||
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
|
||||
/tmp/rustfs-init.sh
|
||||
echo "Waiting for MinIO to be ready..."
|
||||
ready=0
|
||||
for i in {1..60}; do
|
||||
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
|
||||
echo "MinIO is up after ${i} seconds"
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "::error::MinIO did not become ready within 60 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mc alias set local http://localhost:9000 devminio devminio123
|
||||
mc mb --ignore-existing local/formbricks-e2e
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
@@ -221,14 +238,8 @@ jobs:
|
||||
if: failure()
|
||||
with:
|
||||
name: app-logs
|
||||
if-no-files-found: ignore
|
||||
path: app.log
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
run: |
|
||||
if [ -f app.log ]; then
|
||||
cat app.log
|
||||
else
|
||||
echo "app.log not found because the Run App step did not execute or failed before log creation."
|
||||
fi
|
||||
run: cat app.log
|
||||
|
||||
@@ -31,14 +31,14 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Get the latest release tag from GitHub API with error handling
|
||||
echo "Fetching latest release from GitHub API..."
|
||||
|
||||
|
||||
# Use curl with error handling - API returns 404 if no releases exist
|
||||
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
|
||||
|
||||
|
||||
if [[ "$http_code" == "404" ]]; then
|
||||
echo "⚠️ No previous releases found (404). This appears to be the first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
echo "Current release tag: ${{ github.event.release.tag_name }}"
|
||||
|
||||
- name: Compare release tags
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Handle first release case (no previous releases)
|
||||
if [[ -z "${LATEST_TAG}" ]]; then
|
||||
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
|
||||
@@ -155,113 +155,3 @@ jobs:
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
update-helm-app-version:
|
||||
name: Create Helm app version update
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- helm-chart-release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install YQ
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Prepare Helm app version update
|
||||
id: update
|
||||
env:
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
|
||||
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
|
||||
|
||||
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
|
||||
echo "Helm chart appVersion already matches ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Helm app version PR
|
||||
if: steps.update.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="chore/update-helm-app-version-${VERSION}"
|
||||
title="chore: update Helm app version to ${VERSION}"
|
||||
body_file="$(mktemp)"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$branch"
|
||||
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
cat > "$body_file" <<EOF
|
||||
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
|
||||
|
||||
Release candidates and pre-releases do not create this source update.
|
||||
EOF
|
||||
|
||||
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
|
||||
else
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
|
||||
fi
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
- update-helm-app-version
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Complete Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Linear Release Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linear-release:
|
||||
name: Sync release to Linear
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||
with:
|
||||
version: v3.15.4
|
||||
version: latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
env:
|
||||
@@ -70,25 +70,6 @@ jobs:
|
||||
|
||||
echo "✅ Successfully updated Chart.yaml"
|
||||
|
||||
- name: Validate default Formbricks image tag
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
rendered="$(helm template qa charts/formbricks \
|
||||
--set formbricks.webappUrl=https://qa.example.com \
|
||||
--show-only templates/deployment.yaml \
|
||||
--show-only templates/migration-job.yaml)"
|
||||
|
||||
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
|
||||
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
|
||||
if [[ "$image_count" -ne 2 ]]; then
|
||||
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
|
||||
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package Helm chart
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
|
||||
@@ -2,7 +2,6 @@ name: Translation Validation
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -40,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -50,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Validate translation keys
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
apps/web/.env
|
||||
@@ -1,48 +0,0 @@
|
||||
# Accessibility
|
||||
|
||||
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
|
||||
|
||||
## Standards
|
||||
|
||||
We aim to conform to:
|
||||
|
||||
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
|
||||
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
|
||||
- **Section 508** — for users in US public-sector contexts.
|
||||
|
||||
## Priorities
|
||||
|
||||
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
|
||||
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
|
||||
|
||||
In both areas we focus on:
|
||||
|
||||
- Keyboard navigation with a clearly visible focus indicator
|
||||
- Screen reader support through semantic HTML and correctly scoped ARIA
|
||||
- Sufficient color and contrast
|
||||
- Programmatically associated labels and announced status messages
|
||||
|
||||
## Supported Environments
|
||||
|
||||
- Latest two versions of Chrome, Firefox, Safari, and Edge
|
||||
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing UI changes:
|
||||
|
||||
- Prefer semantic HTML over ARIA.
|
||||
- Tab through your change end-to-end and confirm focus is visible at every stop.
|
||||
- Label every control. Don't convey meaning by color alone.
|
||||
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
|
||||
|
||||
## Reporting Accessibility Issues
|
||||
|
||||
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
|
||||
|
||||
## Resources
|
||||
|
||||
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
|
||||
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
|
||||
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
|
||||
@@ -99,58 +99,6 @@ Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs coloc
|
||||
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
|
||||
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
|
||||
|
||||
## Workbench Workflow
|
||||
|
||||
The project workbench lives at `workbench/`.
|
||||
|
||||
Start substantial tasks with targeted workbench context, not a full-doc sweep. Use `rg`, indexes, headings, and linked records to find the smallest relevant set, then open only the sections needed for the task:
|
||||
|
||||
1. `workbench/blueprint/PRODUCT.md`
|
||||
2. `workbench/blueprint/EPICS.md`
|
||||
3. `workbench/blueprint/business-rules/`
|
||||
4. `workbench/blueprint/decisions/`
|
||||
5. `workbench/cowork/COORDINATOR.md`
|
||||
6. `workbench/blueprint/MILESTONES.md`
|
||||
7. The relevant plan, checkpoint, bug-fix, setup, env, security, check, or manual QA file.
|
||||
|
||||
`workbench/GUIDE.md` is the workflow source of truth. Keep `AGENTS.md` concise and route detailed workflow rules there.
|
||||
|
||||
### Workbench Token Budget
|
||||
|
||||
Prefer narrow input and compact output. Do not read entire workbench directories or paste long excerpts unless the task requires it. Summarize findings, link files, and report only changed records plus validation results. For routine implementation, one concise checkpoint is enough after an end-to-end pass.
|
||||
|
||||
### Blueprint vs Cowork
|
||||
|
||||
- `workbench/blueprint/` contains durable product and application truth: product definition, epics, milestones, business rules, decisions, design, security, checks, manual QA, and env var expectations.
|
||||
- `workbench/cowork/` contains active execution records: plans, checkpoints, bug fixes, prompts, and coordination.
|
||||
- `workbench/scratch/` and `workbench/local/` are ignored local spaces. Do not rely on their contents for durable project state.
|
||||
|
||||
### Blueprint Human Ownership
|
||||
|
||||
AI agents may help improve wording, structure, consistency, and traceability for `workbench/blueprint/` documents. The underlying concepts, original prompts or drafts, and final review must come from humans. Treat blueprint records as human-owned product truth: do not invent product direction, business rules, milestones, decisions, security posture, env expectations, checks, manual QA, or design guidance without explicit human input and review.
|
||||
|
||||
### Workbench Review Gates
|
||||
|
||||
Do not start planning or implementation from a milestone, plan, or bug-fix record unless it has a completed human review line such as `- [ ] Reviewed and refined by: Javier`. If the line is missing or still says `TBD`, stop and ask for human review. Keep checkpoints proportional: one checkpoint is enough when a plan is implemented end to end in one pass.
|
||||
|
||||
### Workbench Validation
|
||||
|
||||
After editing `workbench/`, `skills/`, or this workflow section in `AGENTS.md`, run `node workbench/scripts/validate-workbench.mjs workbench` and report any failures or relevant warnings. Before implementing from workbench records, run the validator when the workbench structure, links, or review gates may have changed since the plan was reviewed.
|
||||
|
||||
### Documentation Sync
|
||||
|
||||
When code changes alter product behavior, business rules, architecture decisions, env vars, setup, security posture, automated checks, or manual QA expectations, update the relevant workbench files in the same change. Avoid process churn: do not create or rewrite workbench docs for purely mechanical implementation details, formatting, or phase-by-phase narration when one concise checkpoint covers an end-to-end implementation.
|
||||
|
||||
Use checkpoints for completed plan phases. Use bug-fix records for scoped defects. Use decision records for durable tradeoffs. Use business-rule records for current domain behavior.
|
||||
|
||||
## Git Discipline
|
||||
|
||||
- Do not create commits unless explicitly asked.
|
||||
- Preserve staged and unstaged work exactly.
|
||||
- Do not stage, unstage, reset, or discard files unless explicitly asked.
|
||||
- Before editing files in an existing repo, inspect `git status --short` and check relevant staged and unstaged diffs.
|
||||
- Treat staged content as human-selected work in progress.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
||||
|
||||
+12
-13
@@ -5,26 +5,25 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "5.0.2",
|
||||
"@storybook/addon-a11y": "10.3.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-links": "10.3.6",
|
||||
"@storybook/addon-onboarding": "10.3.6",
|
||||
"@storybook/react-vite": "10.3.6",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.17",
|
||||
"@storybook/addon-links": "10.2.17",
|
||||
"@storybook/addon-onboarding": "10.2.17",
|
||||
"@storybook/react-vite": "10.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.3.6",
|
||||
"storybook": "10.3.6",
|
||||
"vite": "7.3.3"
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.2",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
|
||||
name: team.name,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
-11
@@ -2,7 +2,6 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
|
||||
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "surveys",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
-8
@@ -2,7 +2,6 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -24,13 +23,6 @@ const Page = async (props: ModePageProps) => {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const experimentVariant =
|
||||
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
|
||||
|
||||
if (experimentVariant === "remove-cx-and-surveys-mode") {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const channelOptions = [
|
||||
{
|
||||
|
||||
+10
-5
@@ -1,17 +1,22 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||
import { type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface SelectPlanOnboardingProps {
|
||||
organizationId: string;
|
||||
variant: TPlanVariant;
|
||||
}
|
||||
|
||||
export const SelectPlanOnboarding = ({ organizationId, variant }: Readonly<SelectPlanOnboardingProps>) => {
|
||||
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
|
||||
const t = await getTranslate();
|
||||
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} variant={variant} />
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
|
||||
<Header
|
||||
title={t("workspace.settings.billing.select_plan_header_title")}
|
||||
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
|
||||
/>
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-21
@@ -1,14 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { PLAN_VARIANTS, type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||
|
||||
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
const VALID_VARIANTS = new Set<TPlanVariant>(PLAN_VARIANTS);
|
||||
|
||||
interface PlanPageProps {
|
||||
params: Promise<{
|
||||
@@ -39,24 +36,7 @@ const Page = async (props: PlanPageProps) => {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
let variant: TPlanVariant = "control";
|
||||
const flagValue = await getPostHogFeatureFlag(
|
||||
session.user.id,
|
||||
"a-b_onboarding_trial-conversion-screen-copy",
|
||||
{
|
||||
organizationId: params.organizationId,
|
||||
}
|
||||
);
|
||||
if (typeof flagValue === "string" && VALID_VARIANTS.has(flagValue as TPlanVariant)) {
|
||||
variant = flagValue as TPlanVariant;
|
||||
}
|
||||
|
||||
const selectPlanOnboardingProps = {
|
||||
organizationId: params.organizationId,
|
||||
variant,
|
||||
};
|
||||
|
||||
return <SelectPlanOnboarding {...selectPlanOnboardingProps} />;
|
||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@ import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/acti
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
|
||||
@@ -238,7 +237,7 @@ export const WorkspaceSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))}
|
||||
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
|
||||
-37
@@ -11,16 +11,12 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
||||
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
|
||||
interface WorkspaceSettingsPageProps {
|
||||
params: Promise<{
|
||||
@@ -47,29 +43,8 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
const channel = searchParams.channel ?? null;
|
||||
const industry = searchParams.industry ?? null;
|
||||
const mode = searchParams.mode ?? "surveys";
|
||||
|
||||
const experimentVariant =
|
||||
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-theme-screen")) || "control";
|
||||
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
if (experimentVariant === "remove-theme") {
|
||||
const existing = workspaces.find((w) => w.name === organization.name);
|
||||
const workspace =
|
||||
existing ??
|
||||
(await createWorkspace(params.organizationId, {
|
||||
name: organization.name,
|
||||
styling: buildStylingFromBrandColor(DEFAULT_BRAND_COLOR),
|
||||
config: { channel, industry },
|
||||
}));
|
||||
if (channel === "app" || channel === "website") {
|
||||
return redirect(`/workspaces/${workspace.id}/connect`);
|
||||
} else if (channel === "link") {
|
||||
return redirect(`/workspaces/${workspace.id}/surveys`);
|
||||
}
|
||||
return redirect(`/workspaces/${workspace.id}/xm-templates`);
|
||||
}
|
||||
|
||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
@@ -80,18 +55,6 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
if (searchParams.mode === "cx") {
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "cx",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -81,19 +80,6 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"workspace_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspace.id,
|
||||
name: workspace.name,
|
||||
},
|
||||
{ organizationId, workspaceId: workspace.id }
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspace.id;
|
||||
ctx.auditLoggingCtx.newObject = workspace;
|
||||
|
||||
@@ -13,13 +13,11 @@ import {
|
||||
MessageSquareTextIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlayCircleIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -43,7 +41,6 @@ import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { TRIAL_BASE_RESPONSE_LIMIT, TrialBannerNew } from "@/modules/ee/billing/components/trial-banner-new";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
@@ -76,8 +73,6 @@ interface NavigationProps {
|
||||
organizationWorkspacesLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
responseCount: number;
|
||||
newTrialBannerVariant: string | boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
@@ -92,8 +87,6 @@ export const MainNavigation = ({
|
||||
organizationWorkspacesLimit,
|
||||
isLicenseActive,
|
||||
isAccessControlAllowed,
|
||||
responseCount,
|
||||
newTrialBannerVariant,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -166,7 +159,7 @@ export const MainNavigation = ({
|
||||
text="Beta"
|
||||
type="gray"
|
||||
size="tiny"
|
||||
className="text-[10px] font-semibold normal-case tracking-normal"
|
||||
className="normal-case text-[10px] font-semibold tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
@@ -189,41 +182,6 @@ export const MainNavigation = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workflows",
|
||||
name: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{t("common.workflows")}</span>
|
||||
<Badge
|
||||
text="Beta"
|
||||
type="gray"
|
||||
size="tiny"
|
||||
className="text-[10px] font-semibold normal-case tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: t("common.workflows"),
|
||||
href: `/workspaces/${workspace.id}/workflows`,
|
||||
icon: WorkflowIcon,
|
||||
isActive:
|
||||
pathname?.startsWith(`/workspaces/${workspace.id}/workflows`) && !pathname?.includes("/runs"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("workspace.workflows.workflow_runs"),
|
||||
href: `/workspaces/${workspace.id}/workflows/runs`,
|
||||
icon: PlayCircleIcon,
|
||||
isActive:
|
||||
pathname?.startsWith(`/workspaces/${workspace.id}/workflows/runs`) ||
|
||||
(pathname?.startsWith(`/workspaces/${workspace.id}/workflows/`) && pathname?.includes("/runs")),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
@@ -231,7 +189,7 @@ export const MainNavigation = ({
|
||||
const settingsNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.settings"),
|
||||
href: `/workspaces/${workspace.id}/settings/workspace/general`,
|
||||
href: `/workspaces/${workspace.id}/settings`,
|
||||
icon: SettingsIcon,
|
||||
isActive: isSettingsMode,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
@@ -624,26 +582,13 @@ export const MainNavigation = ({
|
||||
)}
|
||||
|
||||
{/* Trial Days Remaining */}
|
||||
{!isCollapsed &&
|
||||
isFormbricksCloud &&
|
||||
trialDaysRemaining !== null &&
|
||||
(newTrialBannerVariant === "new-trial-banner" ? (
|
||||
<TrialBannerNew
|
||||
trialDaysRemaining={trialDaysRemaining}
|
||||
planName={organization.billing.stripe?.plan ?? "pro"}
|
||||
responseCount={responseCount}
|
||||
responseLimit={organization.billing.limits.monthly.responses}
|
||||
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
|
||||
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
|
||||
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`/workspaces/${workspace.id}/settings/organization/billing`}
|
||||
className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
))}
|
||||
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
||||
<Link
|
||||
href={`/workspaces/${workspace.id}/settings/organization/billing`}
|
||||
className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
|
||||
@@ -335,7 +335,6 @@ export const SettingsSidebarContent = ({
|
||||
href: `${basePath}/organization/feedback-directories`,
|
||||
icon: <FoldersIcon className={iconClassName} />,
|
||||
hidden: isMember,
|
||||
disabled: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "org-api-keys",
|
||||
@@ -374,14 +373,12 @@ export const SettingsSidebarContent = ({
|
||||
label: t("common.your_profile"),
|
||||
href: `${basePath}/account/profile`,
|
||||
icon: <UserCircleIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: t("common.notifications"),
|
||||
href: `${basePath}/account/notifications`,
|
||||
icon: <BellIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/T
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
@@ -38,7 +37,6 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
|
||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
|
||||
const newTrialBannerVariant = await getPostHogFeatureFlag(user.id, "a-b_navigation_rich-trial-banner");
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
// Validate that workspace permission exists for members
|
||||
@@ -73,8 +71,6 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isLicenseActive={active}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
responseCount={responseCount}
|
||||
newTrialBannerVariant={newTrialBannerVariant}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -2,8 +2,6 @@ import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { WorkspaceLayout as WorkspaceLayoutComponent } from "@/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout";
|
||||
import { WorkspaceContextWrapper } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getWorkspaceLayoutData } from "@/modules/workspaces/lib/utils";
|
||||
import WorkspaceStorageHandler from "./components/WorkspaceStorageHandler";
|
||||
@@ -25,14 +23,6 @@ const WorkspaceLayout = async (props: {
|
||||
return (
|
||||
<>
|
||||
<WorkspaceStorageHandler workspaceId={params.workspaceId} />
|
||||
{POSTHOG_KEY && (
|
||||
<PostHogGroupIdentify
|
||||
organizationId={layoutData.organization.id}
|
||||
organizationName={layoutData.organization.name}
|
||||
workspaceId={layoutData.workspace.id}
|
||||
workspaceName={layoutData.workspace.name}
|
||||
/>
|
||||
)}
|
||||
<WorkspaceContextWrapper workspace={layoutData.workspace} organization={layoutData.organization}>
|
||||
<WorkspaceLayoutComponent layoutData={layoutData}>{children}</WorkspaceLayoutComponent>
|
||||
</WorkspaceContextWrapper>
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const AccountSettingsLayout = async (
|
||||
props: Readonly<{
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
children: React.ReactNode;
|
||||
}>
|
||||
) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
import { getIsEmailUnique } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
|
||||
+8
-47
@@ -1,67 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import {
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
} from "@/modules/account/constants";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface DeleteAccountProps {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
accountDeletionError?: string | string[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
isSsoIdentityConfirmationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccount = ({
|
||||
session,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
user,
|
||||
organizationsWithSingleOwner,
|
||||
accountDeletionError,
|
||||
isMultiOrgEnabled,
|
||||
requiresPasswordConfirmation,
|
||||
isSsoIdentityConfirmationDisabled,
|
||||
}: Readonly<DeleteAccountProps>) => {
|
||||
}: {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
}) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||
const { t } = useTranslation();
|
||||
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
|
||||
? accountDeletionError[0]
|
||||
: accountDeletionError;
|
||||
const hasShownAccountDeletionError = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
|
||||
hasShownAccountDeletionError.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownAccountDeletionError.current = true;
|
||||
|
||||
toast.error(t("workspace.settings.profile.sso_identity_confirmation_failed"), {
|
||||
id: "account-deletion-sso-confirmation-error",
|
||||
});
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
||||
globalThis.history.replaceState(null, "", url.toString());
|
||||
}, [accountDeletionErrorCode, t]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
@@ -69,13 +32,11 @@ export const DeleteAccount = ({
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isSsoIdentityConfirmationDisabled={isSsoIdentityConfirmationDisabled}
|
||||
/>
|
||||
<p className="text-sm text-slate-700">
|
||||
<strong>{t("workspace.settings.profile.warning_cannot_undo")}</strong>
|
||||
|
||||
+87
-1
@@ -1,6 +1,12 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getIsEmailUnique } from "./user";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -11,12 +17,92 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -3,17 +3,10 @@ import { AccountSecurity } from "@/app/(app)/workspaces/[workspaceId]/settings/a
|
||||
import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/DeleteAccount";
|
||||
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/EditProfileDetailsForm";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import {
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
PASSWORD_RESET_DISABLED,
|
||||
} from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -21,14 +14,10 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
|
||||
}) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslate();
|
||||
const { session } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
@@ -41,7 +30,6 @@ const Page = async (props: {
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -72,7 +60,7 @@ const Page = async (props: {
|
||||
: t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/workspaces/${params.workspaceId}/settings/organization/billing`
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
@@ -97,9 +85,6 @@ const Page = async (props: {
|
||||
user={user}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
isSsoIdentityConfirmationDisabled={DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getBillingFallbackPath: vi.fn(),
|
||||
getWorkspaceAuth: vi.fn(),
|
||||
isFormbricksCloud: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/navigation", () => ({
|
||||
getBillingFallbackPath: mocks.getBillingFallbackPath,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/workspaces/lib/utils", () => ({
|
||||
getWorkspaceAuth: mocks.getWorkspaceAuth,
|
||||
}));
|
||||
|
||||
const workspaceId = "workspace-1";
|
||||
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
|
||||
|
||||
const getWorkspaceAuthResponse = (isBilling: boolean) =>
|
||||
({
|
||||
isBilling,
|
||||
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
|
||||
|
||||
describe("redirectBillingRoleFromRestrictedSettings", () => {
|
||||
test("does not redirect non-billing workspace members", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
|
||||
|
||||
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).not.toHaveBeenCalled();
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects billing users to the billing fallback path", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
|
||||
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
|
||||
|
||||
await redirectBillingRoleFromRestrictedSettings(workspaceId);
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
|
||||
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
|
||||
const { isBilling } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
if (isBilling) {
|
||||
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return APIKeysPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default APIKeysPage;
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { PricingPage } from "@/modules/ee/billing/page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && !IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
return PricingPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default PricingPage;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -13,9 +12,8 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
|
||||
+5
@@ -66,6 +66,11 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "aiDataAnalysis",
|
||||
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "auditLogs",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
|
||||
|
||||
+7
-12
@@ -5,7 +5,7 @@ import { RotateCcwIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
@@ -151,17 +151,12 @@ export const EnterpriseLicenseStatus = ({
|
||||
</Alert>
|
||||
)}
|
||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||
<Trans
|
||||
i18nKey="workspace.settings.enterprise.questions_please_reach_out_to_email"
|
||||
components={{
|
||||
contactLink: (
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{t("workspace.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com">
|
||||
hola@formbricks.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
+6
-11
@@ -1,10 +1,9 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -12,19 +11,15 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
if (isPricingDisabled) {
|
||||
@@ -168,7 +163,7 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
referrerPolicy="no-referrer">
|
||||
|
||||
+1
-11
@@ -1,11 +1 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return FeedbackDirectoriesPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
|
||||
|
||||
+6
@@ -57,6 +57,7 @@ describe("organization AI settings actions", () => {
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
||||
@@ -65,6 +66,7 @@ describe("organization AI settings actions", () => {
|
||||
mocks.updateOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
||||
});
|
||||
@@ -112,15 +114,18 @@ describe("organization AI settings actions", () => {
|
||||
oldObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
newObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,6 +194,7 @@ describe("organization AI settings actions", () => {
|
||||
mocks.getOrganization.mockResolvedValueOnce({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||
|
||||
|
||||
+9
-2
@@ -71,11 +71,12 @@ export const updateOrganizationNameAction = authenticatedActionClient
|
||||
|
||||
type TOrganizationAISettings = Pick<
|
||||
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
|
||||
"isAISmartToolsEnabled"
|
||||
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
|
||||
>;
|
||||
|
||||
type TResolvedOrganizationAISettings = {
|
||||
smartToolsEnabled: boolean;
|
||||
dataAnalysisEnabled: boolean;
|
||||
isEnablingAnyAISetting: boolean;
|
||||
};
|
||||
|
||||
@@ -89,10 +90,16 @@ const resolveOrganizationAISettings = ({
|
||||
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
|
||||
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
|
||||
: organization.isAISmartToolsEnabled;
|
||||
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
|
||||
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
|
||||
: organization.isAIDataAnalysisEnabled;
|
||||
|
||||
return {
|
||||
smartToolsEnabled,
|
||||
isEnablingAnyAISetting: smartToolsEnabled && !organization.isAISmartToolsEnabled,
|
||||
dataAnalysisEnabled,
|
||||
isEnablingAnyAISetting:
|
||||
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
|
||||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+29
-8
@@ -21,7 +21,6 @@ interface AISettingsToggleProps {
|
||||
isInstanceAIConfigured: boolean;
|
||||
hasAIPermission: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const AISettingsToggle = ({
|
||||
@@ -30,7 +29,6 @@ export const AISettingsToggle = ({
|
||||
isInstanceAIConfigured,
|
||||
hasAIPermission,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: Readonly<AISettingsToggleProps>) => {
|
||||
const { workspace } = useWorkspace();
|
||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
||||
@@ -50,18 +48,29 @@ export const AISettingsToggle = ({
|
||||
currentValue: organization.isAISmartToolsEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
||||
currentValue: organization.isAIDataAnalysisEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
const handleToggle = async (
|
||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
||||
checked: boolean
|
||||
) => {
|
||||
if (checked && !aiEnablementState.canEnableFeatures) {
|
||||
toast.error(aiEnablementBlockedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingField("isAISmartToolsEnabled");
|
||||
setLoadingField(field);
|
||||
try {
|
||||
const data =
|
||||
field === "isAISmartToolsEnabled"
|
||||
? { isAISmartToolsEnabled: checked }
|
||||
: { isAIDataAnalysisEnabled: checked };
|
||||
const response = await updateOrganizationAISettingsAction({
|
||||
organizationId: organization.id,
|
||||
data: { isAISmartToolsEnabled: checked },
|
||||
data,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
@@ -82,11 +91,13 @@ export const AISettingsToggle = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `${workspaceBasePath}/settings/organization/billing`
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: "https://formbricks.com/docs/platform/features/ai-features",
|
||||
href: isFormbricksCloud
|
||||
? `${workspaceBasePath}/settings/organization/billing`
|
||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -111,7 +122,7 @@ export const AISettingsToggle = ({
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={displayedSmartToolsValue}
|
||||
onToggle={handleToggle}
|
||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
||||
htmlId="ai-smart-tools-toggle"
|
||||
title={t("workspace.settings.general.ai_smart_tools_enabled")}
|
||||
description={t("workspace.settings.general.ai_smart_tools_enabled_description")}
|
||||
@@ -119,6 +130,16 @@ export const AISettingsToggle = ({
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={displayedDataAnalysisValue}
|
||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
||||
htmlId="ai-data-analysis-toggle"
|
||||
title={t("workspace.settings.general.ai_data_analysis_enabled")}
|
||||
description={t("workspace.settings.general.ai_data_analysis_enabled_description")}
|
||||
disabled={isToggleDisabled}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
{!canEdit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
|
||||
+11
-16
@@ -1,14 +1,9 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
FB_LOGO_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import {
|
||||
getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
getWhiteLabelPermission,
|
||||
@@ -26,9 +21,8 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
|
||||
@@ -37,11 +31,14 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAIPermission] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
]);
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
|
||||
await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
getIsAIDataAnalysisEnabled(organization.id),
|
||||
]);
|
||||
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
|
||||
|
||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||
const currentUserRole = currentUserMembership?.role;
|
||||
@@ -73,7 +70,6 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
isInstanceAIConfigured={isInstanceAIConfigured()}
|
||||
hasAIPermission={hasAIPermission}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<EmailCustomizationSettings
|
||||
@@ -85,7 +81,6 @@ const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
user={user}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
<SettingsCard
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
|
||||
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
});
|
||||
|
||||
export const ZUpdateOrganizationAISettingsAction = z.object({
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { TeamsPage } from "@/modules/organization/settings/teams/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return TeamsPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default TeamsPage;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
|
||||
};
|
||||
|
||||
|
||||
+4
-10
@@ -46,16 +46,10 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
workspace_id: parsedInput.workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId: parsedInput.workspaceId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
|
||||
-4
@@ -18,7 +18,6 @@ interface AirtableWrapperProps {
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
showReconnectButton?: boolean;
|
||||
}
|
||||
|
||||
export const AirtableWrapper = ({
|
||||
@@ -29,7 +28,6 @@ export const AirtableWrapper = ({
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
showReconnectButton = false,
|
||||
}: AirtableWrapperProps) => {
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
airtableIntegration ? airtableIntegration.config?.key : false
|
||||
@@ -51,8 +49,6 @@ export const AirtableWrapper = ({
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
locale={locale}
|
||||
showReconnectButton={showReconnectButton}
|
||||
handleAirtableAuthorization={handleAirtableAuthorization}
|
||||
/>
|
||||
) : (
|
||||
<ConnectIntegration
|
||||
|
||||
+9
-38
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,11 +12,9 @@ import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/se
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
@@ -26,20 +24,10 @@ interface ManageIntegrationProps {
|
||||
surveys: TSurvey[];
|
||||
airtableArray: TIntegrationItem[];
|
||||
locale: TUserLocale;
|
||||
showReconnectButton: boolean;
|
||||
handleAirtableAuthorization: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
airtableIntegration,
|
||||
workspaceId,
|
||||
setIsConnected,
|
||||
surveys,
|
||||
airtableArray,
|
||||
showReconnectButton,
|
||||
handleAirtableAuthorization,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, workspaceId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -85,34 +73,15 @@ export const ManageIntegration = ({
|
||||
: { isEditMode: false as const };
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
{showReconnectButton && (
|
||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||
<AlertDescription>{t("workspace.integrations.reconnect_button_description")}</AlertDescription>
|
||||
<AlertButton onClick={handleAirtableAuthorization}>
|
||||
{t("workspace.integrations.reconnect_button")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<div className="flex w-full justify-end gap-x-6">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
<span className="cursor-pointer text-slate-500">
|
||||
{t("workspace.integrations.connected_with_email", {
|
||||
email: airtableIntegration.config.email,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleAirtableAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.integrations.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("workspace.integrations.reconnect_button_tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDefaultValues(null);
|
||||
@@ -153,7 +122,9 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||
<div className="col-span-2 text-center">{data.elements}</div>
|
||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+1
-9
@@ -1,5 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
@@ -32,14 +31,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
);
|
||||
|
||||
let airtableArray: TIntegrationItem[] = [];
|
||||
let isTokenValid = true;
|
||||
if (airtableIntegration?.config.key) {
|
||||
try {
|
||||
airtableArray = await getAirtableTables(workspace.id);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
|
||||
isTokenValid = false;
|
||||
}
|
||||
airtableArray = await getAirtableTables(workspace.id);
|
||||
}
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
@@ -58,7 +51,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
showReconnectButton={!isTokenValid}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
+15
-41
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
ElementOption,
|
||||
ElementOptions,
|
||||
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
|
||||
|
||||
export interface DateRange {
|
||||
from: Date | undefined;
|
||||
to?: Date;
|
||||
to?: Date | undefined;
|
||||
}
|
||||
|
||||
interface FilterDateContextProps {
|
||||
@@ -41,8 +41,6 @@ interface FilterDateContextProps {
|
||||
dateRange: DateRange;
|
||||
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
|
||||
resetState: () => void;
|
||||
refreshAnalysisData: () => Promise<void>;
|
||||
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
|
||||
}
|
||||
|
||||
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
|
||||
@@ -63,7 +61,6 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
from: undefined,
|
||||
to: getTodayDate(),
|
||||
});
|
||||
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setDateRange({
|
||||
@@ -76,43 +73,20 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshAnalysisData = useCallback(async () => {
|
||||
await refreshHandlerRef.current?.();
|
||||
}, []);
|
||||
|
||||
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
|
||||
refreshHandlerRef.current = handler;
|
||||
|
||||
return () => {
|
||||
if (refreshHandlerRef.current === handler) {
|
||||
refreshHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
}),
|
||||
[
|
||||
dateRange,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
resetState,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
]
|
||||
return (
|
||||
<ResponseFilterContext.Provider
|
||||
value={{
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
}}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
);
|
||||
|
||||
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
|
||||
};
|
||||
|
||||
const useResponseFilter = () => {
|
||||
|
||||
+2
-35
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -14,7 +12,6 @@ import { useResponseFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/
|
||||
import { ResponseDataView } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
|
||||
interface ResponsePageProps {
|
||||
@@ -46,8 +43,8 @@ export const ResponsePage = ({
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
|
||||
@@ -86,34 +83,6 @@ export const ResponsePage = ({
|
||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||
};
|
||||
|
||||
const refetchResponses = useCallback(async () => {
|
||||
setIsFetchingFirstPage(true);
|
||||
|
||||
try {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
if (getResponsesActionResponse?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
|
||||
}
|
||||
|
||||
const freshResponses = getResponsesActionResponse?.data ?? [];
|
||||
setResponses(freshResponses);
|
||||
setPage(1);
|
||||
setHasMore(freshResponses.length >= responsesPerPage);
|
||||
} finally {
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
}, [filters, responsesPerPage, surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refetchResponses);
|
||||
}, [refetchResponses, registerAnalysisRefreshHandler]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
}, [survey]);
|
||||
@@ -162,8 +131,6 @@ export const ResponsePage = ({
|
||||
}
|
||||
};
|
||||
fetchFilteredResponses();
|
||||
// page is intentionally omitted to avoid refetching after the initial page setup.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { capitalize } from "lodash";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import { TResponseMeta } from "@formbricks/types/responses";
|
||||
import { capitalize } from "@/lib/utils/object";
|
||||
|
||||
export const getAddressFieldLabel = (field: string, t: TFunction) => {
|
||||
switch (field) {
|
||||
|
||||
+2
-7
@@ -2,12 +2,7 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
RESPONSES_PER_PAGE,
|
||||
} from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -69,6 +64,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -77,7 +73,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation survey={survey} activeId="responses" />
|
||||
|
||||
+5
-18
@@ -2,9 +2,8 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -139,6 +138,7 @@ export const getEmailHtmlAction = authenticatedActionClient
|
||||
const ZGeneratePersonalLinksAction = z.object({
|
||||
surveyId: ZId,
|
||||
segmentId: ZId,
|
||||
workspaceId: ZId,
|
||||
expirationDays: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -146,7 +146,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.inputSchema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
throw new OperationNotAllowedError("Contacts are not enabled for this workspace");
|
||||
@@ -154,7 +153,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -162,7 +161,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId,
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -176,21 +175,9 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
);
|
||||
|
||||
if (!contactsResult || contactsResult.length === 0) {
|
||||
throw new InvalidInputError("No contacts found for the selected segment");
|
||||
throw new UnknownError("No contacts found for the selected segment");
|
||||
}
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"personal_link_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
survey_id: parsedInput.surveyId,
|
||||
link_count: contactsResult.length,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
|
||||
// Prepare CSV data with the specified headers and order
|
||||
const csvHeaders = [
|
||||
"Formbricks Contact ID",
|
||||
|
||||
+6
-3
@@ -4,12 +4,15 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
export const SuccessMessage = () => {
|
||||
const { survey } = useSurvey();
|
||||
interface SummaryMetadataProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
+19
-35
@@ -68,7 +68,7 @@ export const SummaryPage = ({
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||
@@ -108,7 +108,7 @@ export const SummaryPage = ({
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays]);
|
||||
}, [fetchDisplays, t]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
@@ -128,39 +128,13 @@ export const SummaryPage = ({
|
||||
}
|
||||
}, [tab, loadInitialDisplays]);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
const updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
if (updatedSurveySummary?.serverError) {
|
||||
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
|
||||
}
|
||||
|
||||
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
|
||||
}, [dateRange, selectedFilter, survey, surveyId]);
|
||||
|
||||
const refreshSummary = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchSummary, loadInitialDisplays, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refreshSummary);
|
||||
}, [refreshSummary, registerAnalysisRefreshHandler]);
|
||||
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
const hasNoFilters =
|
||||
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
|
||||
(!selectedFilter ||
|
||||
Object.keys(selectedFilter).length === 0 ||
|
||||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
|
||||
(!dateRange || (!dateRange.from && !dateRange.to));
|
||||
|
||||
if (initialSurveySummary && hasNoFilters) {
|
||||
@@ -168,11 +142,21 @@ export const SummaryPage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchFilteredSummary = async () => {
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await fetchSummary();
|
||||
// Recalculate filters inside the effect to ensure we have the latest values
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
@@ -180,8 +164,8 @@ export const SummaryPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
fetchFilteredSummary();
|
||||
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
|
||||
+10
-37
@@ -1,18 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { useResponseFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { SuccessMessage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
@@ -23,6 +22,7 @@ import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
isReadOnly: boolean;
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
@@ -31,7 +31,6 @@ interface SurveyAnalysisCTAProps {
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -40,6 +39,7 @@ interface ModalState {
|
||||
}
|
||||
|
||||
export const SurveyAnalysisCTA = ({
|
||||
survey,
|
||||
isReadOnly,
|
||||
user,
|
||||
publicDomain,
|
||||
@@ -48,7 +48,6 @@ export const SurveyAnalysisCTA = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
isStorageConfigured,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -61,12 +60,9 @@ export const SurveyAnalysisCTA = ({
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const { survey } = useSurvey();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
const { refreshAnalysisData } = useResponseFilter();
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && workspace.appSetupCompleted;
|
||||
|
||||
@@ -78,7 +74,7 @@ export const SurveyAnalysisCTA = ({
|
||||
}, [searchParams]);
|
||||
|
||||
const handleShareModalToggle = (open: boolean) => {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const currentShareParam = params.get("share") === "true";
|
||||
|
||||
if (open && !currentShareParam) {
|
||||
@@ -113,12 +109,9 @@ export const SurveyAnalysisCTA = ({
|
||||
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const singleUseLinkParams = await refreshSingleUseId();
|
||||
if (singleUseLinkParams) {
|
||||
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
surveyUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,25 +144,6 @@ export const SurveyAnalysisCTA = ({
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: RefreshCcwIcon,
|
||||
tooltip: t("common.refresh"),
|
||||
onClick: async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshAnalysisData();
|
||||
toast.success(t("common.data_refreshed_successfully"));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
disabled: isRefreshing,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: BellRing,
|
||||
tooltip: t("workspace.surveys.summary.configure_alerts"),
|
||||
@@ -206,7 +180,7 @@ export const SurveyAnalysisCTA = ({
|
||||
return (
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
||||
<SurveyStatusDropdown />
|
||||
<SurveyStatusDropdown survey={survey} />
|
||||
)}
|
||||
|
||||
<IconBar actions={iconActions} />
|
||||
@@ -236,10 +210,9 @@ export const SurveyAnalysisCTA = ({
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
workspaceCustomScripts={workspace.customHeadScripts}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage />
|
||||
<SuccessMessage survey={survey} />
|
||||
|
||||
{responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
|
||||
+1
-3
@@ -54,7 +54,6 @@ interface ShareSurveyModalProps {
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
workspaceCustomScripts?: string | null;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -70,7 +69,6 @@ export const ShareSurveyModal = ({
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
workspaceCustomScripts,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||
@@ -104,11 +102,11 @@ export const ShareSurveyModal = ({
|
||||
description: t("workspace.surveys.share.personal_links.description"),
|
||||
componentType: PersonalLinksTab,
|
||||
componentProps: {
|
||||
workspaceId: survey.workspaceId,
|
||||
surveyId: survey.id,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
|
||||
+17
-52
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -41,7 +41,6 @@ export const AnonymousLinksTab = ({
|
||||
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
|
||||
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
|
||||
const [customSingleUseId, setCustomSingleUseId] = useState("");
|
||||
|
||||
const [disableLinkModal, setDisableLinkModal] = useState<{
|
||||
open: boolean;
|
||||
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
|
||||
pendingAction: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
const surveyUrlWithCustomSuid = useMemo(() => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", "CUSTOM-ID");
|
||||
return url.toString();
|
||||
}, [surveyUrl]);
|
||||
|
||||
const resetState = () => {
|
||||
const { singleUse } = survey;
|
||||
const { enabled, isEncrypted } = singleUse ?? {};
|
||||
@@ -176,13 +181,10 @@ export const AnonymousLinksTab = ({
|
||||
});
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseLinkParams = response.data;
|
||||
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", suId);
|
||||
if (suToken) {
|
||||
url.searchParams.set("suToken", suToken);
|
||||
}
|
||||
url.searchParams.set("suId", singleUseId);
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
@@ -210,40 +212,6 @@ export const AnonymousLinksTab = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCustomSingleUseLink = async () => {
|
||||
const trimmedCustomSingleUseId = customSingleUseId.trim();
|
||||
if (!trimmedCustomSingleUseId) {
|
||||
toast.error(t("workspace.surveys.share.anonymous_links.custom_single_use_id_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: false,
|
||||
count: 1,
|
||||
singleUseId: trimmedCustomSingleUseId,
|
||||
});
|
||||
|
||||
const singleUseLinkParams = response?.data?.[0];
|
||||
if (!singleUseLinkParams) {
|
||||
toast.error(t("workspace.surveys.share.anonymous_links.generate_links_error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
url.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
} catch {
|
||||
toast.error(t("workspace.surveys.share.anonymous_links.generate_links_error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
@@ -309,19 +277,16 @@ export const AnonymousLinksTab = ({
|
||||
</Alert>
|
||||
|
||||
<div className="grid w-full grid-cols-6 items-center gap-2">
|
||||
<Input
|
||||
className="col-span-5 bg-white focus:border focus:border-slate-900"
|
||||
value={customSingleUseId}
|
||||
onChange={(event) => setCustomSingleUseId(event.target.value)}
|
||||
placeholder={t(
|
||||
"workspace.surveys.share.anonymous_links.custom_single_use_id_placeholder"
|
||||
)}
|
||||
/>
|
||||
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
|
||||
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!customSingleUseId.trim()}
|
||||
onClick={handleCopyCustomSingleUseLink}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
<CopyIcon />
|
||||
|
||||
+5
-69
@@ -2,7 +2,7 @@
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import { CopyIcon, SendIcon } from "lucide-react";
|
||||
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
@@ -21,7 +21,6 @@ interface EmailTabProps {
|
||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const emailHtml = useMemo(() => {
|
||||
@@ -32,40 +31,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const sanitizedEmailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const emailPreviewDocument = useMemo(() => {
|
||||
if (!sanitizedEmailHtml) return "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="only light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
supported-color-schemes: light;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color-scheme: only light;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${sanitizedEmailHtml}</body>
|
||||
</html>`;
|
||||
}, [sanitizedEmailHtml]);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "preview",
|
||||
@@ -86,25 +51,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
getData();
|
||||
}, [surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewFrameHeight(560);
|
||||
}, [emailPreviewDocument]);
|
||||
|
||||
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
|
||||
const { contentDocument } = event.currentTarget;
|
||||
if (!contentDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHeight = Math.max(
|
||||
contentDocument.body.scrollHeight,
|
||||
contentDocument.documentElement.scrollHeight,
|
||||
560
|
||||
);
|
||||
|
||||
setPreviewFrameHeight(nextHeight);
|
||||
};
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
try {
|
||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||
@@ -127,9 +73,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
if (activeTab === "preview") {
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
|
||||
data-testid="survey-email-preview-shell">
|
||||
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
@@ -143,17 +87,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
{t("workspace.surveys.share.send_email.email_subject_label")} :{" "}
|
||||
{t("workspace.surveys.share.send_email.formbricks_email_survey_preview")}
|
||||
</div>
|
||||
<div data-testid="survey-email-preview-content">
|
||||
{emailPreviewDocument ? (
|
||||
<iframe
|
||||
className="mt-2 w-full rounded-md border-0 bg-white"
|
||||
data-testid="survey-email-preview-frame"
|
||||
onLoad={handlePreviewFrameLoad}
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
|
||||
srcDoc={emailPreviewDocument}
|
||||
style={{ height: `${previewFrameHeight}px` }}
|
||||
title={t("workspace.surveys.share.send_email.email_preview_tab")}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
+4
-3
@@ -30,11 +30,11 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { generatePersonalLinksAction } from "../../actions";
|
||||
|
||||
interface PersonalLinksTabProps {
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface PersonalLinksFormData {
|
||||
@@ -70,11 +70,11 @@ const RestrictedDatePicker = ({
|
||||
};
|
||||
|
||||
export const PersonalLinksTab = ({
|
||||
workspaceId,
|
||||
segments,
|
||||
surveyId,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: PersonalLinksTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
@@ -117,6 +117,7 @@ export const PersonalLinksTab = ({
|
||||
const result = await generatePersonalLinksAction({
|
||||
surveyId: surveyId,
|
||||
segmentId: selectedSegment,
|
||||
workspaceId: workspaceId,
|
||||
expirationDays: expiryDate
|
||||
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
|
||||
: undefined,
|
||||
@@ -170,7 +171,7 @@ export const PersonalLinksTab = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/workspaces/${workspace?.id}/settings/organization/billing`
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+3
-18
@@ -16,19 +16,13 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
|
||||
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
|
||||
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${iframeSrc}"
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
||||
</iframe>
|
||||
</div>`;
|
||||
|
||||
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeBlock language="html" noMargin>
|
||||
@@ -54,15 +48,6 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
|
||||
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
|
||||
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
|
||||
<iframe
|
||||
title={t("common.preview")}
|
||||
src={previewSrc}
|
||||
className="absolute inset-0 h-full w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
describe("extractEmailBodyFragment", () => {
|
||||
test("returns the body contents for rendered email documents", () => {
|
||||
const html = `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body class="email-body">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Preview content</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
expect(extractEmailBodyFragment(html)).toBe(
|
||||
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
|
||||
);
|
||||
});
|
||||
|
||||
test("removes document-level tags from rendered survey email markup", () => {
|
||||
const fragment = extractEmailBodyFragment(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Which fruits do you like</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
expect(fragment).toBe(
|
||||
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
|
||||
);
|
||||
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
||||
});
|
||||
|
||||
test("falls back to the original markup when no body tag exists", () => {
|
||||
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
|
||||
});
|
||||
|
||||
test("removes React server markers from rendered fragments", () => {
|
||||
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
|
||||
"<div>Preview content</div>"
|
||||
);
|
||||
});
|
||||
});
|
||||
+5
-4
@@ -1,12 +1,10 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
|
||||
const t = await getTranslate();
|
||||
@@ -19,9 +17,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(workspace, toJsWorkspaceStateSurvey(survey));
|
||||
const styling = getStyling(workspace, survey);
|
||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||
const doctype =
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||
const htmlCleaned = html.toString().replace(doctype, "");
|
||||
|
||||
return extractEmailBodyFragment(html.toString());
|
||||
return htmlCleaned;
|
||||
};
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
|
||||
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
|
||||
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
|
||||
|
||||
export const extractEmailBodyFragment = (html: string): string => {
|
||||
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
|
||||
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
|
||||
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
|
||||
|
||||
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
|
||||
};
|
||||
-15
@@ -1105,21 +1105,6 @@ describe("getSurveySummary", () => {
|
||||
expect.objectContaining({ responseIds: expect.any(Array) })
|
||||
);
|
||||
});
|
||||
|
||||
test("does not pass responseIds for date-only filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = {
|
||||
createdAt: {
|
||||
min: new Date("2024-01-01T00:00:00.000Z"),
|
||||
max: new Date("2024-01-31T23:59:59.999Z"),
|
||||
},
|
||||
};
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
|
||||
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, {
|
||||
createdAt: filterCriteria.createdAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesForSummary", () => {
|
||||
|
||||
+1
-1
@@ -999,7 +999,7 @@ export const getSurveySummary = reactCache(
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const batchSize = 5000;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).some((filterKey) => filterKey !== "createdAt");
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TSurveySummaryResponse[] = [];
|
||||
|
||||
+2
-7
@@ -4,12 +4,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/s
|
||||
import { SummaryPage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getSurveySummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -69,6 +64,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -77,7 +73,6 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation survey={survey} activeId="summary" />
|
||||
|
||||
@@ -42,25 +42,18 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
|
||||
const result = await getResponseDownloadFile(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.format,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"responses_exported",
|
||||
{
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "responses_exported", {
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
-4
@@ -11,7 +11,6 @@ import {
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
FlagIcon,
|
||||
GaugeIcon,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
SmilePlusIcon,
|
||||
StarIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
@@ -105,8 +103,6 @@ const elementIcons = {
|
||||
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyElementTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
|
||||
[TSurveyElementTypeEnum.CES]: GaugeIcon,
|
||||
[TSurveyElementTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
|
||||
+10
-3
@@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
@@ -16,8 +15,12 @@ import {
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
|
||||
export const SurveyStatusDropdown = () => {
|
||||
const { survey } = useSurvey();
|
||||
interface SurveyStatusDropdownProps {
|
||||
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
|
||||
@@ -39,6 +42,10 @@ export const SurveyStatusDropdown = () => {
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
|
||||
if (updateLocalSurveyStatus) {
|
||||
updateLocalSurveyStatus(resultingStatus);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { WorkflowBuilderLoading } from "@/modules/workflows/loading";
|
||||
|
||||
export default WorkflowBuilderLoading;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { WorkflowBuilderPage } from "@/modules/workflows/pages/workflow-builder-page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const WorkflowPage = async (
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
|
||||
) => {
|
||||
const { workspaceId, workflowId } = await props.params;
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
return <WorkflowBuilderPage workspaceId={workspaceId} workflowId={workflowId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
export default WorkflowPage;
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
import { WorkflowRunDetailLoading } from "@/modules/workflows/loading";
|
||||
|
||||
export default WorkflowRunDetailLoading;
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
import { WorkflowRunDetailPage } from "@/modules/workflows/pages/workflow-run-detail-page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const WorkflowRunDetail = async (
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string; runId: string }> }>
|
||||
) => {
|
||||
const { workspaceId, workflowId, runId } = await props.params;
|
||||
await getWorkspaceAuth(workspaceId);
|
||||
|
||||
return <WorkflowRunDetailPage workspaceId={workspaceId} workflowId={workflowId} runId={runId} />;
|
||||
};
|
||||
|
||||
export default WorkflowRunDetail;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { WorkflowRunsLoading } from "@/modules/workflows/loading";
|
||||
|
||||
export default WorkflowRunsLoading;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { WorkflowRunsPage } from "@/modules/workflows/pages/workflow-runs-page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const WorkflowRuns = async (
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string; workflowId: string }> }>
|
||||
) => {
|
||||
const { workspaceId, workflowId } = await props.params;
|
||||
await getWorkspaceAuth(workspaceId);
|
||||
|
||||
return <WorkflowRunsPage workspaceId={workspaceId} workflowId={workflowId} />;
|
||||
};
|
||||
|
||||
export default WorkflowRuns;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { WorkflowsListLoading } from "@/modules/workflows/loading";
|
||||
|
||||
export default WorkflowsListLoading;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { WorkflowsListPage } from "@/modules/workflows/pages/workflows-list-page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const WorkflowsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
return <WorkflowsListPage workspaceId={workspaceId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
export default WorkflowsPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { WorkspaceWorkflowRunsLoading } from "@/modules/workflows/loading";
|
||||
|
||||
export default WorkspaceWorkflowRunsLoading;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { WorkspaceWorkflowRunsPage } from "@/modules/workflows/pages/workspace-workflow-runs-page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const WorkflowRuns = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
await getWorkspaceAuth(workspaceId);
|
||||
|
||||
return <WorkspaceWorkflowRunsPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default WorkflowRuns;
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
|
||||
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockConstants = vi.hoisted(() => ({
|
||||
isFormbricksCloud: false,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockConstants.isFormbricksCloud;
|
||||
},
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
verifyAccountDeletionSsoReauthIntent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/account/lib/account-deletion", () => ({
|
||||
deleteUserWithAccountDeletionAuthorization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/account/lib/account-deletion-audit", () => ({
|
||||
queueAccountDeletionAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetServerSession = vi.mocked(getServerSession);
|
||||
const mockLoggerError = vi.mocked(logger.error);
|
||||
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
|
||||
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
|
||||
const mockQueueAccountDeletionAuditEvent = vi.mocked(queueAccountDeletionAuditEvent);
|
||||
|
||||
const intent = {
|
||||
id: "intent-id",
|
||||
email: "delete-user@example.com",
|
||||
provider: "google",
|
||||
providerAccountId: "google-account-id",
|
||||
purpose: "account_deletion_sso_reauth" as const,
|
||||
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
|
||||
userId: "user-id",
|
||||
};
|
||||
|
||||
describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConstants.isFormbricksCloud = false;
|
||||
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: intent.email,
|
||||
id: intent.userId,
|
||||
},
|
||||
} as any);
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
|
||||
oldUser: { id: intent.userId } as any,
|
||||
});
|
||||
mockQueueAccountDeletionAuditEvent.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("returns login without deleting when the callback has no intent", async () => {
|
||||
await expect(completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({})).resolves.toBe(
|
||||
"/auth/login"
|
||||
);
|
||||
|
||||
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockQueueAccountDeletionAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deletes the account after a completed SSO identity confirmation", async () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
confirmationEmail: intent.email,
|
||||
userEmail: intent.email,
|
||||
userId: intent.userId,
|
||||
});
|
||||
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
|
||||
oldUser: { id: intent.userId },
|
||||
status: "success",
|
||||
targetUserId: intent.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("redirects to the account deletion survey after SSO identity confirmation on Formbricks Cloud", async () => {
|
||||
mockConstants.isFormbricksCloud = true;
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
confirmationEmail: intent.email,
|
||||
userEmail: intent.email,
|
||||
userId: intent.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not delete when the callback session does not match the intent user", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: "other@example.com",
|
||||
id: "other-user-id",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(AuthorizationError) },
|
||||
"Failed to complete account deletion after SSO identity confirmation"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns to the profile page with an error when deletion fails after SSO identity confirmation", async () => {
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockRejectedValue(
|
||||
new AuthorizationError("marker missing")
|
||||
);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
|
||||
status: "failure",
|
||||
targetUserId: intent.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
|
||||
mockQueueAccountDeletionAuditEvent.mockRejectedValue(new Error("audit unavailable"));
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
"Failed to complete account deletion after SSO identity confirmation"
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to login when the intent return URL is not allowed", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
|
||||
...intent,
|
||||
returnToUrl: "https://evil.example/settings/profile",
|
||||
});
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: "other@example.com",
|
||||
id: "other-user-id",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: ["intent-token"] })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import "server-only";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
|
||||
} from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
type TAccountDeletionSsoCompleteSearchParams = {
|
||||
intent?: string | string[];
|
||||
};
|
||||
|
||||
const getIntentToken = (intent: string | string[] | undefined) => {
|
||||
if (Array.isArray(intent)) {
|
||||
return intent[0];
|
||||
}
|
||||
|
||||
return intent;
|
||||
};
|
||||
|
||||
const getSafeFailureRedirectPath = (returnToUrl: string) => {
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
return "/auth/login";
|
||||
}
|
||||
|
||||
const parsedReturnToUrl = new URL(validatedReturnToUrl);
|
||||
parsedReturnToUrl.searchParams.set(
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
|
||||
);
|
||||
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
|
||||
};
|
||||
|
||||
const getPostDeletionRedirectPath = () =>
|
||||
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
|
||||
|
||||
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
|
||||
intent,
|
||||
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
|
||||
const intentToken = getIntentToken(intent);
|
||||
let deletionSucceeded = false;
|
||||
let redirectPath = "/auth/login";
|
||||
let targetUserId: string | null = null;
|
||||
|
||||
if (!intentToken) {
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
targetUserId = verifiedIntent.userId;
|
||||
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
||||
throw new AuthorizationError("Account deletion SSO identity confirmation session mismatch");
|
||||
}
|
||||
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail: verifiedIntent.email,
|
||||
userEmail: session.user.email,
|
||||
userId: session.user.id,
|
||||
});
|
||||
deletionSucceeded = true;
|
||||
redirectPath = getPostDeletionRedirectPath();
|
||||
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
|
||||
} catch (error) {
|
||||
if (targetUserId && !deletionSucceeded) {
|
||||
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
|
||||
}
|
||||
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
|
||||
}
|
||||
|
||||
return redirectPath;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
const getIntentSearchParam = (request: NextRequest): string | string[] | undefined => {
|
||||
const intentValues = request.nextUrl.searchParams.getAll("intent");
|
||||
|
||||
if (intentValues.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return intentValues.length === 1 ? intentValues[0] : intentValues;
|
||||
};
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const redirectPath = await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({
|
||||
intent: getIntentSearchParam(request),
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL(redirectPath, request.url));
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
|
||||
|
||||
const handler = async (request: NextRequest): Promise<Response> => {
|
||||
return await authorizeTraefikRequest(request);
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const HEAD = handler;
|
||||
export const OPTIONS = handler;
|
||||
@@ -185,20 +185,4 @@ describe("auth route audit logging", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => {
|
||||
const authOptions = await getWrappedAuthOptions("req-sso-recovery");
|
||||
const user = {
|
||||
id: "user_4",
|
||||
email: "user4@example.com",
|
||||
authFlowPurpose: "sso_recovery",
|
||||
};
|
||||
const account = { provider: "token" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
|
||||
await authOptions.events.signIn({ user, account, isNewUser: false });
|
||||
|
||||
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user