mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 03:07:53 -05:00
Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa398f1a1d | |||
| d90482212a | |||
| 1e0d003dd6 | |||
| 4b1b377cf7 | |||
| 4463d5731d | |||
| bf4303cdb5 | |||
| b3debbf0f6 | |||
| e2bf79ce6c | |||
| 1032702b65 | |||
| 10a3ac4dc6 | |||
| eea7df81b4 | |||
| c172e2a33c | |||
| 9a5780d510 | |||
| e333d8ba02 | |||
| 16463960ad | |||
| 3e5a4ca4c8 | |||
| a7370ac4a0 | |||
| 073c28e5e9 | |||
| 104b04bdc0 | |||
| e81360f354 | |||
| f90a9fb131 | |||
| d7627fe6c3 | |||
| 939fedfca4 | |||
| 00f93eec10 | |||
| c286a3330a | |||
| d22db8d735 | |||
| 2db02dac5e | |||
| ce68d58aaf | |||
| 6774f220b1 | |||
| efe259d484 | |||
| 96b08fbe23 | |||
| 4eba194935 | |||
| 9d8cf5e0f7 | |||
| 3c6f6d83ea | |||
| 1380c81bff | |||
| 535c111860 | |||
| 676e31c433 | |||
| 88f17380e1 | |||
| 103775b3b1 | |||
| 3005c44c49 | |||
| 4d03ba2ff7 | |||
| bc25c482ad | |||
| b08f7e4ad9 | |||
| 89a8266ebe | |||
| cad10b8810 | |||
| 1d18b5cb83 | |||
| 69ead97965 | |||
| 7e2c439325 | |||
| a2177eec96 | |||
| 255c97854f | |||
| d103499496 | |||
| b863238f15 | |||
| 28280899ea | |||
| bc63870289 | |||
| 9a04e95d15 | |||
| 9d9f38515d | |||
| fae00f6a82 | |||
| a274c444ad | |||
| 5fae207cd7 | |||
| 654539d320 | |||
| 44aac89d41 | |||
| e0250b2a58 | |||
| a8d6cd8a9f | |||
| f79fe1490e | |||
| 5cfbc671c5 | |||
| e6e9419b93 | |||
| 90eb78e571 | |||
| 991866f549 | |||
| 6d1b3475d4 | |||
| 5e967a2b67 | |||
| 23a8fd6a47 | |||
| 0686eb3cbb | |||
| 6d9ab315c2 | |||
| 4128731c5f | |||
| ef96426ca0 | |||
| ce1dbe8b00 | |||
| 444f043140 | |||
| 2d32c0d671 | |||
| 8dc70a5e30 | |||
| 3e4e55fbf1 | |||
| fcfedd6e15 | |||
| 6c4342690f | |||
| b8c361fcf3 | |||
| 8771a0ec91 | |||
| fc33c52133 | |||
| 75cf9293b1 | |||
| e489c6a346 | |||
| cefc2bdf60 | |||
| 78473bf3d0 | |||
| 15403c6a92 | |||
| 35b98863a4 | |||
| 65f5968fb1 | |||
| 2dfea4d72f | |||
| ff77118932 | |||
| 79a773432a | |||
| d53869f1df | |||
| fc9ddb2b0d | |||
| 6fcb6863bd | |||
| b1cee91ad9 | |||
| 60bd5cbeff | |||
| b6a3a15379 | |||
| c68f214eff | |||
| c90ee84483 | |||
| dc1ee72594 | |||
| 924132287e | |||
| e6f347aa07 | |||
| 367bc23dd4 | |||
| a1a11b2bb8 | |||
| 0653c6a59f | |||
| b6d793e109 | |||
| 439dd0b44e | |||
| 2556f5e15d | |||
| cc0eec3bf0 | |||
| 4b009a8eb4 | |||
| 2aaddf7306 | |||
| fb5d6145d0 | |||
| 59310bac93 | |||
| 322f0be197 | |||
| 1a02f91afd | |||
| cc22ccb22d | |||
| 12763f0ef6 | |||
| d39e3ee638 | |||
| d85242a86b | |||
| ef53065abc | |||
| 805c1c6874 | |||
| 01687e8907 | |||
| 31d455002d | |||
| d96304d86d | |||
| 1064f68435 | |||
| 3d16e859c6 | |||
| af198c5632 | |||
| a43ed2b25c | |||
| 87bcad2b20 | |||
| b5eaa4c7fd | |||
| 995c03bc01 | |||
| b4395a48c5 | |||
| 461e3893fe | |||
| 735a9f84ec | |||
| 8cb8d734cf | |||
| 44d5530b48 | |||
| a314eb391e | |||
| 6c34c316d0 | |||
| 4f26278f16 | |||
| b975e7fa2e | |||
| 6c3052f9e4 | |||
| 5bb8119ebf | |||
| 02411277d4 | |||
| 4cfb8c6d7b | |||
| e74a51a5ff | |||
| 29cc6a10fe | |||
| 01f765e969 | |||
| 9366960f18 | |||
| 697dc9cc99 | |||
| 83bc272ed2 | |||
| 59cc9c564e | |||
| 20dc147682 | |||
| 2bb7a6f277 | |||
| deb062dd03 | |||
| 474be86d33 | |||
| e7ca66ed77 | |||
| 2b49dbecd3 | |||
| 6da4c6f352 | |||
| 659b240fca | |||
| 19c0b1d14d | |||
| b4472f48e9 | |||
| d197271771 | |||
| 37f652c70e | |||
| 645f0ab0d1 | |||
| 389a7d9e7b | |||
| c4cf468c7e | |||
| cbc3e923e4 | |||
| a96ba8b1e7 | |||
| e830871361 | |||
| 998e5c0819 | |||
| 13a56b0237 | |||
| 0b5418a03a | |||
| 0d8a338965 | |||
| d3250736a9 | |||
| e6ee6a6b0d | |||
| c0b097f929 | |||
| 78d336f8c7 | |||
| 95a7a265b9 | |||
| 136e59da68 | |||
| eb0a87cf80 | |||
| 0dcb98ac29 | |||
| 540f7aaae7 | |||
| 2d4614a0bd | |||
| 633bf18204 | |||
| 9a6cbd05b6 | |||
| 94b0248075 | |||
| 082de1042d | |||
| 8c19587baa | |||
| 433750d3fe | |||
| 61befd5ffd | |||
| 1e7817fb69 | |||
| f250bc7e88 | |||
| c7faa29437 |
@@ -0,0 +1,9 @@
|
|||||||
|
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||||
|
version = 1
|
||||||
|
name = "formbricks"
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
script = '''
|
||||||
|
pnpm install
|
||||||
|
pnpm dev:setup
|
||||||
|
'''
|
||||||
@@ -349,4 +349,3 @@ When creating a new question element, verify:
|
|||||||
- [ ] TypeScript types properly exported
|
- [ ] TypeScript types properly exported
|
||||||
- [ ] Error message display included if applicable
|
- [ ] Error message display included if applicable
|
||||||
- [ ] Disabled state supported if applicable
|
- [ ] Disabled state supported if applicable
|
||||||
|
|
||||||
|
|||||||
+105
-2
@@ -32,12 +32,60 @@ CRON_SECRET=
|
|||||||
# Set the minimum log level(debug, info, warn, error, fatal)
|
# Set the minimum log level(debug, info, warn, error, fatal)
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
|
||||||
|
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
|
||||||
|
# BULLMQ_WORKER_ENABLED=1
|
||||||
|
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
|
||||||
|
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
|
||||||
|
|
||||||
|
# Number of BullMQ worker instances started per Formbricks server process.
|
||||||
|
# BULLMQ_WORKER_COUNT=1
|
||||||
|
|
||||||
|
# Number of concurrent jobs each BullMQ worker can process.
|
||||||
|
# BULLMQ_WORKER_CONCURRENCY=1
|
||||||
|
|
||||||
|
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
|
||||||
|
# also needs to render the selected execution time and timezone.
|
||||||
|
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
|
||||||
|
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
|
||||||
|
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
|
||||||
|
|
||||||
##############
|
##############
|
||||||
# DATABASE #
|
# DATABASE #
|
||||||
##############
|
##############
|
||||||
|
|
||||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
||||||
|
|
||||||
|
#################
|
||||||
|
# HUB (DEV) #
|
||||||
|
#################
|
||||||
|
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
|
||||||
|
# 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 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 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.
|
||||||
|
CUBEJS_API_URL=http://localhost:4000
|
||||||
|
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
|
||||||
|
CUBEJS_API_SECRET=
|
||||||
|
CUBEJS_JWT_ISSUER=formbricks-web
|
||||||
|
CUBEJS_JWT_AUDIENCE=formbricks-cube
|
||||||
|
|
||||||
################
|
################
|
||||||
# MAIL SETUP #
|
# MAIL SETUP #
|
||||||
################
|
################
|
||||||
@@ -70,7 +118,7 @@ SMTP_PASSWORD=smtpPassword
|
|||||||
# S3 STORAGE #
|
# S3 STORAGE #
|
||||||
##############
|
##############
|
||||||
|
|
||||||
# S3 Storage is required for the file upload in serverless environments like Vercel
|
# S3 Storage is required for the file upload in serverless environments
|
||||||
S3_ACCESS_KEY=
|
S3_ACCESS_KEY=
|
||||||
S3_SECRET_KEY=
|
S3_SECRET_KEY=
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
@@ -94,12 +142,26 @@ EMAIL_VERIFICATION_DISABLED=1
|
|||||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||||
PASSWORD_RESET_DISABLED=1
|
PASSWORD_RESET_DISABLED=1
|
||||||
|
|
||||||
|
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
||||||
|
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
||||||
|
|
||||||
|
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
||||||
|
# DEBUG_SHOW_RESET_LINK=1
|
||||||
|
|
||||||
# Email login. Disable the ability for users to login with email.
|
# Email login. Disable the ability for users to login with email.
|
||||||
# EMAIL_AUTH_DISABLED=1
|
# EMAIL_AUTH_DISABLED=1
|
||||||
|
|
||||||
# Organization Invite. Disable the ability for invited users to create an account.
|
# Organization Invite. Disable the ability for invited users to create an account.
|
||||||
# INVITE_DISABLED=1
|
# 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 #
|
# Other #
|
||||||
@@ -132,6 +194,32 @@ AZUREAD_CLIENT_ID=
|
|||||||
AZUREAD_CLIENT_SECRET=
|
AZUREAD_CLIENT_SECRET=
|
||||||
AZUREAD_TENANT_ID=
|
AZUREAD_TENANT_ID=
|
||||||
|
|
||||||
|
# Configure Formbricks AI at the instance level
|
||||||
|
# Set the provider used for AI features on this instance.
|
||||||
|
# Accepted values for AI_PROVIDER: aws, google, azure
|
||||||
|
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
|
||||||
|
# AI_PROVIDER=google
|
||||||
|
# AI_MODEL=gemini-2.5-flash
|
||||||
|
|
||||||
|
# Google Cloud settings for Gemini models
|
||||||
|
# Credentials are optional when Application Default Credentials are available.
|
||||||
|
# AI_GOOGLE_CLOUD_PROJECT=
|
||||||
|
# AI_GOOGLE_CLOUD_LOCATION=
|
||||||
|
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
|
||||||
|
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
|
||||||
|
|
||||||
|
# Amazon Bedrock credentials
|
||||||
|
# AI_AWS_REGION=
|
||||||
|
# AI_AWS_ACCESS_KEY_ID=
|
||||||
|
# AI_AWS_SECRET_ACCESS_KEY=
|
||||||
|
# AI_AWS_SESSION_TOKEN=
|
||||||
|
|
||||||
|
# Azure AI / Microsoft Foundry credentials
|
||||||
|
# AI_AZURE_BASE_URL=
|
||||||
|
# AI_AZURE_RESOURCE_NAME=
|
||||||
|
# AI_AZURE_API_KEY=
|
||||||
|
# AI_AZURE_API_VERSION=v1
|
||||||
|
|
||||||
# OpenID Connect (OIDC) configuration
|
# OpenID Connect (OIDC) configuration
|
||||||
# OIDC_CLIENT_ID=
|
# OIDC_CLIENT_ID=
|
||||||
# OIDC_CLIENT_SECRET=
|
# OIDC_CLIENT_SECRET=
|
||||||
@@ -185,6 +273,14 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
|
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
||||||
|
# TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||||
|
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||||
|
# that need to send webhooks to internal services.
|
||||||
|
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
||||||
|
|
||||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||||
@@ -229,6 +325,13 @@ REDIS_URL=redis://localhost:6379
|
|||||||
# If the ip should be added in the log or not. Default 0
|
# If the ip should be added in the log or not. Default 0
|
||||||
# AUDIT_LOG_GET_USER_IP=0
|
# AUDIT_LOG_GET_USER_IP=0
|
||||||
|
|
||||||
|
# Optional Cube.js database overrides. The official local docker-compose.dev.yml stack points Cube at the
|
||||||
|
# local `postgres` service automatically; set these only when running Cube yourself or changing bundled defaults.
|
||||||
|
# CUBEJS_DB_HOST=postgres
|
||||||
|
# CUBEJS_DB_PORT=5432
|
||||||
|
# CUBEJS_DB_NAME=postgres
|
||||||
|
# CUBEJS_DB_USER=postgres
|
||||||
|
# CUBEJS_DB_PASS=postgres
|
||||||
|
|
||||||
# Lingo.dev API key for translation generation
|
# Lingo.dev API key for translation generation
|
||||||
LINGODOTDEV_API_KEY=your_api_key_here
|
LINGO_API_KEY=your_api_key_here
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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)
|
||||||
@@ -284,6 +284,10 @@ runs:
|
|||||||
database_url=${{ env.DUMMY_DATABASE_URL }}
|
database_url=${{ env.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
redis_url=${{ env.DUMMY_REDIS_URL }}
|
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||||
|
hub_api_url=${{ env.DUMMY_HUB_API_URL }}
|
||||||
|
hub_api_key=${{ env.DUMMY_HUB_API_KEY }}
|
||||||
|
cubejs_api_url=${{ env.DUMMY_CUBEJS_API_URL }}
|
||||||
|
cubejs_api_secret=${{ env.DUMMY_CUBEJS_API_SECRET }}
|
||||||
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
posthog_key=${{ env.POSTHOG_KEY }}
|
posthog_key=${{ env.POSTHOG_KEY }}
|
||||||
env:
|
env:
|
||||||
@@ -291,6 +295,10 @@ runs:
|
|||||||
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||||
|
DUMMY_HUB_API_URL: ${{ env.DUMMY_HUB_API_URL }}
|
||||||
|
DUMMY_HUB_API_KEY: ${{ env.DUMMY_HUB_API_KEY }}
|
||||||
|
DUMMY_CUBEJS_API_URL: ${{ env.DUMMY_CUBEJS_API_URL }}
|
||||||
|
DUMMY_CUBEJS_API_SECRET: ${{ env.DUMMY_CUBEJS_API_SECRET }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
|
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
|
||||||
- name: Cache Build
|
- name: Cache Build
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
id: cache-build
|
id: cache-build
|
||||||
env:
|
env:
|
||||||
cache-name: prod-build
|
cache-name: prod-build
|
||||||
@@ -43,7 +43,7 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Setup Node.js 20.x
|
- name: Setup Node.js 20.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||||
@@ -53,20 +53,18 @@ runs:
|
|||||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: create .env
|
- name: Create .env
|
||||||
run: cp .env.example .env
|
run: pnpm dev:setup
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
- name: Fill E2E_TESTING in .env
|
||||||
env:
|
env:
|
||||||
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
|
||||||
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|||||||
@@ -91,5 +91,9 @@ jobs:
|
|||||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
|
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
|
||||||
|
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
|
||||||
|
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
|
||||||
|
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Run Chromatic
|
- name: Run Chromatic
|
||||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ jobs:
|
|||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
redis_url=redis://localhost:6379
|
redis_url=redis://localhost:6379
|
||||||
|
hub_api_url=http://localhost:4000
|
||||||
|
hub_api_key=build-time-placeholder
|
||||||
|
cubejs_api_url=http://localhost:4000
|
||||||
|
cubejs_api_secret=build-time-placeholder
|
||||||
|
|
||||||
- name: Verify and Initialize PostgreSQL
|
- name: Verify and Initialize PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
@@ -143,6 +147,10 @@ jobs:
|
|||||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||||
-e REDIS_URL="redis://host.docker.internal:6379" \
|
-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"
|
-d "formbricks-test:$GITHUB_SHA"
|
||||||
|
|
||||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||||
|
|||||||
+40
-55
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
|
||||||
- name: Setup Node.js 22.x
|
- name: Setup Node.js 22.x
|
||||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
|
|
||||||
@@ -65,19 +65,15 @@ jobs:
|
|||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: create .env
|
- name: Create .env
|
||||||
run: cp .env.example .env
|
run: pnpm dev:setup
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||||
echo "" >> .env
|
echo "" >> .env
|
||||||
@@ -85,65 +81,48 @@ jobs:
|
|||||||
echo "S3_REGION=us-east-1" >> .env
|
echo "S3_REGION=us-east-1" >> .env
|
||||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||||
echo "S3_ACCESS_KEY=devminio" >> .env
|
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
|
||||||
echo "S3_SECRET_KEY=devminio123" >> .env
|
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
|
||||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install MinIO client (mc)
|
- name: Start RustFS Server
|
||||||
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Start MinIO server in background
|
# Start RustFS server in background
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name minio-server \
|
--name rustfs-server \
|
||||||
-p 9000:9000 \
|
-p 9000:9000 \
|
||||||
-p 9001:9001 \
|
-p 9001:9001 \
|
||||||
-e MINIO_ROOT_USER=devminio \
|
-e RUSTFS_ACCESS_KEY=devrustfs \
|
||||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
-e RUSTFS_SECRET_KEY=devrustfs123 \
|
||||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
-e RUSTFS_ADDRESS=:9000 \
|
||||||
server /data --console-address :9001
|
-e RUSTFS_CONSOLE_ENABLE=true \
|
||||||
|
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
|
||||||
|
rustfs/rustfs:1.0.0-alpha.93 \
|
||||||
|
/data
|
||||||
|
|
||||||
echo "MinIO server started"
|
echo "RustFS server started"
|
||||||
|
|
||||||
- name: Wait for MinIO and create S3 bucket
|
- name: Bootstrap RustFS bucket and browser upload CORS
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Waiting for MinIO to be ready..."
|
docker run --rm \
|
||||||
ready=0
|
--network host \
|
||||||
for i in {1..60}; do
|
--entrypoint /bin/sh \
|
||||||
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
|
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
|
||||||
echo "MinIO is up after ${i} seconds"
|
-e RUSTFS_ADMIN_USER=devrustfs \
|
||||||
ready=1
|
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
|
||||||
break
|
-e RUSTFS_SERVICE_USER=devrustfs-service \
|
||||||
fi
|
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
|
||||||
sleep 1
|
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
|
||||||
done
|
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
|
||||||
|
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
|
||||||
if [ "$ready" -ne 1 ]; then
|
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
|
||||||
echo "::error::MinIO did not become ready within 60 seconds"
|
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
|
||||||
exit 1
|
/tmp/rustfs-init.sh
|
||||||
fi
|
|
||||||
|
|
||||||
mc alias set local http://localhost:9000 devminio devminio123
|
|
||||||
mc mb --ignore-existing local/formbricks-e2e
|
|
||||||
|
|
||||||
- name: Build App
|
- name: Build App
|
||||||
run: |
|
run: |
|
||||||
@@ -242,8 +221,14 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: app-logs
|
name: app-logs
|
||||||
|
if-no-files-found: ignore
|
||||||
path: app.log
|
path: app.log
|
||||||
|
|
||||||
- name: Output App Logs
|
- name: Output App Logs
|
||||||
if: failure()
|
if: failure()
|
||||||
run: cat app.log
|
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
|
||||||
|
|||||||
@@ -155,3 +155,31 @@ jobs:
|
|||||||
commit_sha: ${{ github.sha }}
|
commit_sha: ${{ github.sha }}
|
||||||
is_prerelease: ${{ github.event.release.prerelease }}
|
is_prerelease: ${{ github.event.release.prerelease }}
|
||||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||||
|
|
||||||
|
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
|
||||||
|
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 }}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
|
||||||
- name: Setup Node.js 20.x
|
- name: Setup Node.js 20.x
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
|
||||||
@@ -29,17 +29,10 @@ jobs:
|
|||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: create .env
|
- name: Create .env
|
||||||
run: cp .env.example .env
|
run: pnpm dev:setup
|
||||||
|
|
||||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
|
||||||
run: |
|
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|||||||
@@ -47,4 +47,8 @@ jobs:
|
|||||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
|
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
|
||||||
|
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
|
||||||
|
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
|
||||||
|
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
@@ -105,4 +105,8 @@ jobs:
|
|||||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
|
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
|
||||||
|
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
|
||||||
|
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
|
||||||
|
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
|
|
||||||
- name: Setup Node.js 22.x
|
- name: Setup Node.js 22.x
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
|
|
||||||
@@ -33,17 +33,13 @@ jobs:
|
|||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: create .env
|
- name: Create .env
|
||||||
run: cp .env.example .env
|
run: pnpm dev:setup
|
||||||
|
|
||||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
- name: Adjust CI-specific env values
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
|
||||||
- name: Setup Node.js 20.x
|
- name: Setup Node.js 20.x
|
||||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
|
||||||
@@ -30,17 +30,13 @@ jobs:
|
|||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: create .env
|
- name: Create .env
|
||||||
run: cp .env.example .env
|
run: pnpm dev:setup
|
||||||
|
|
||||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
- name: Adjust CI-specific env values
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ name: Translation Validation
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -39,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Node.js 22.x
|
- name: Setup Node.js 22.x
|
||||||
if: steps.changes.outputs.translations == 'true'
|
if: steps.changes.outputs.translations == 'true'
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.changes.outputs.translations == 'true'
|
if: steps.changes.outputs.translations == 'true'
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Validate translation keys
|
- name: Validate translation keys
|
||||||
if: steps.changes.outputs.translations == 'true'
|
if: steps.changes.outputs.translations == 'true'
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ yarn-error.log*
|
|||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
/test-results/
|
**/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|||||||
+13
-1
@@ -1 +1,13 @@
|
|||||||
pnpm lint-staged
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
pnpm lint-staged
|
||||||
|
elif command -v npm >/dev/null 2>&1; then
|
||||||
|
npm exec --yes pnpm@10.32.1 lint-staged
|
||||||
|
elif command -v corepack >/dev/null 2>&1; then
|
||||||
|
corepack pnpm lint-staged
|
||||||
|
else
|
||||||
|
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
|
||||||
|
echo "Install Node.js tooling or update your PATH, then retry the commit."
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
@@ -1 +0,0 @@
|
|||||||
apps/web/.env
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 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)
|
||||||
@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
|||||||
|
|
||||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||||
We are using SonarQube to identify code smells and security hotspots.
|
We are using SonarQube to identify code smells and security hotspots.
|
||||||
|
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||||
|
|
||||||
## Architecture & Patterns
|
## Architecture & Patterns
|
||||||
|
|
||||||
@@ -52,6 +53,14 @@ We are using SonarQube to identify code smells and security hotspots.
|
|||||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
|
## Date and Time Rendering
|
||||||
|
|
||||||
|
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
||||||
|
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
||||||
|
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
||||||
|
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
||||||
|
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
||||||
|
|
||||||
## Database & Prisma Performance
|
## Database & Prisma Performance
|
||||||
|
|
||||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
|||||||
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
|||||||
|
|
||||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||||
|
|
||||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||||
|
|
||||||
#### Community-managed One Click Hosting
|
|
||||||
|
|
||||||
##### Railway
|
|
||||||
|
|
||||||
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
|
||||||
|
|
||||||
[](https://railway.app/new/template/PPDzCd)
|
|
||||||
|
|
||||||
##### RepoCloud
|
|
||||||
|
|
||||||
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
|
||||||
|
|
||||||
[](https://repocloud.io/details/?app_id=254)
|
|
||||||
|
|
||||||
##### Zeabur
|
|
||||||
|
|
||||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
|
||||||
|
|
||||||
[](https://zeabur.com/templates/G4TUJL)
|
|
||||||
|
|
||||||
<a id="development"></a>
|
|
||||||
|
|
||||||
## 👨💻 Development
|
## 👨💻 Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
|||||||
|
|
||||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||||
|
|
||||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
<a id="readme-de"></a>
|
||||||
|
|||||||
+12
-12
@@ -11,19 +11,19 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^5.0.1",
|
"@chromatic-com/storybook": "5.0.2",
|
||||||
"@storybook/addon-a11y": "10.2.17",
|
"@storybook/addon-a11y": "10.3.6",
|
||||||
"@storybook/addon-links": "10.2.17",
|
"@storybook/addon-docs": "10.3.6",
|
||||||
"@storybook/addon-onboarding": "10.2.17",
|
"@storybook/addon-links": "10.3.6",
|
||||||
"@storybook/react-vite": "10.2.17",
|
"@storybook/addon-onboarding": "10.3.6",
|
||||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
"@storybook/react-vite": "10.3.6",
|
||||||
"@tailwindcss/vite": "4.2.1",
|
"@tailwindcss/vite": "4.2.4",
|
||||||
"@typescript-eslint/parser": "8.57.0",
|
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||||
|
"@typescript-eslint/parser": "8.57.2",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"eslint-plugin-react-refresh": "0.4.26",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"eslint-plugin-storybook": "10.2.17",
|
"eslint-plugin-storybook": "10.3.6",
|
||||||
"storybook": "10.2.17",
|
"storybook": "10.3.6",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.3"
|
||||||
"@storybook/addon-docs": "10.2.17"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ RUN pnpm build --filter=@formbricks/database
|
|||||||
RUN --mount=type=secret,id=database_url \
|
RUN --mount=type=secret,id=database_url \
|
||||||
--mount=type=secret,id=encryption_key \
|
--mount=type=secret,id=encryption_key \
|
||||||
--mount=type=secret,id=redis_url \
|
--mount=type=secret,id=redis_url \
|
||||||
|
--mount=type=secret,id=hub_api_url \
|
||||||
|
--mount=type=secret,id=hub_api_key \
|
||||||
|
--mount=type=secret,id=cubejs_api_url \
|
||||||
|
--mount=type=secret,id=cubejs_api_secret \
|
||||||
--mount=type=secret,id=sentry_auth_token \
|
--mount=type=secret,id=sentry_auth_token \
|
||||||
--mount=type=secret,id=posthog_key \
|
--mount=type=secret,id=posthog_key \
|
||||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { XIcon } from "lucide-react";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
|
||||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Header } from "@/modules/ui/components/header";
|
|
||||||
|
|
||||||
interface XMTemplatePageProps {
|
|
||||||
params: Promise<{
|
|
||||||
environmentId: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Page = async (props: XMTemplatePageProps) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const environment = await getEnvironment(params.environmentId);
|
|
||||||
const t = await getTranslate();
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error(t("common.user_not_found"));
|
|
||||||
}
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.workspace_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
|
||||||
<Header title={t("environments.xm-templates.headline")} />
|
|
||||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
|
||||||
{projects.length >= 2 && (
|
|
||||||
<Button
|
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
|
||||||
variant="ghost"
|
|
||||||
asChild>
|
|
||||||
<Link href={`/environments/${environment.id}/surveys`}>
|
|
||||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
|
|||||||
|
|
||||||
test("throws DatabaseError on Prisma error", async () => {
|
test("throws DatabaseError on Prisma error", async () => {
|
||||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||||
);
|
);
|
||||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { Prisma } from "@prisma/client";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
|
|||||||
name: team.name,
|
name: team.name,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
-31
@@ -1,20 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowUpRightIcon,
|
||||||
|
Building2Icon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
Loader2,
|
||||||
|
LogOutIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
@@ -22,14 +34,65 @@ import {
|
|||||||
interface LandingSidebarProps {
|
interface LandingSidebarProps {
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
|
isMultiOrgEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: LandingSidebarProps) => {
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||||
|
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||||
|
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
||||||
|
const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
|
const loadOrganizations = useCallback(async () => {
|
||||||
|
setIsLoadingOrganizations(true);
|
||||||
|
setOrganizationLoadError(null);
|
||||||
|
try {
|
||||||
|
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
|
||||||
|
if (result?.data) {
|
||||||
|
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setOrganizations(sorted);
|
||||||
|
} else {
|
||||||
|
setOrganizationLoadError(
|
||||||
|
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOrganizationLoadError(t("common.failed_to_load_organizations"));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOrganizations(false);
|
||||||
|
}
|
||||||
|
}, [organization.id, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isOrgDropdownOpen &&
|
||||||
|
organizations.length === 0 &&
|
||||||
|
!isLoadingOrganizations &&
|
||||||
|
!organizationLoadError
|
||||||
|
) {
|
||||||
|
loadOrganizations();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isOrgDropdownOpen,
|
||||||
|
organizations.length,
|
||||||
|
isLoadingOrganizations,
|
||||||
|
organizationLoadError,
|
||||||
|
loadOrganizations,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleOrganizationChange = (orgId: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setIsOrgDropdownOpen(false);
|
||||||
|
router.push(`/organizations/${orgId}/`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
{
|
{
|
||||||
label: t("common.documentation"),
|
label: t("common.documentation"),
|
||||||
@@ -39,52 +102,109 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const switcherTriggerClasses =
|
||||||
|
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset";
|
||||||
|
const switcherIconClasses =
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||||
)}>
|
)}>
|
||||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex flex-col">
|
||||||
|
{/* Organization Switcher */}
|
||||||
|
<DropdownMenu onOpenChange={setIsOrgDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
|
||||||
|
<button type="button" className="flex w-full items-center gap-3">
|
||||||
|
<span className={switcherIconClasses}>
|
||||||
|
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
<div className="grow overflow-hidden">
|
||||||
|
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||||
|
</div>
|
||||||
|
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
|
||||||
|
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.change_organization")}
|
||||||
|
</div>
|
||||||
|
{isLoadingOrganizations && (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingOrganizations && organizationLoadError && (
|
||||||
|
<div className="px-2 py-4">
|
||||||
|
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOrganizationLoadError(null);
|
||||||
|
setOrganizations([]);
|
||||||
|
}}
|
||||||
|
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
|
{t("common.try_again")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingOrganizations && !organizationLoadError && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={org.id}
|
||||||
|
checked={org.id === organization.id}
|
||||||
|
onClick={() => handleOrganizationChange(org.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{org.name}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
{isMultiOrgEnabled && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||||
|
className="w-full cursor-pointer justify-between">
|
||||||
|
<span>{t("common.create_new_organization")}</span>
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* User Dropdown */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||||
<button
|
<button type="button" className="flex w-full items-center gap-3">
|
||||||
type="button"
|
<span className={switcherIconClasses}>
|
||||||
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
|
<ProfileAvatar userId={user.id} />
|
||||||
aria-haspopup="menu">
|
</span>
|
||||||
<ProfileAvatar userId={user.id} />
|
|
||||||
<div className="grow overflow-hidden">
|
<div className="grow overflow-hidden">
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className={cn(
|
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
|
||||||
)}>
|
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p title={organization?.name} className="truncate text-sm text-slate-500">
|
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||||
{organization?.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||||
id="userDropdownInnerContentWrapper"
|
|
||||||
side="right"
|
|
||||||
sideOffset={10}
|
|
||||||
alignOffset={5}
|
|
||||||
align="end">
|
|
||||||
{/* Dropdown Items */}
|
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
id={link.href}
|
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.target}
|
target={link.target}
|
||||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||||
@@ -95,8 +215,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await signOutWithAudit({
|
await signOutWithAudit({
|
||||||
@@ -105,7 +223,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: true,
|
redirect: true,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: "/auth/login",
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
@@ -114,6 +231,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
|
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getEnvironments } from "@/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const LandingLayout = async (props: {
|
const LandingLayout = async (props: {
|
||||||
@@ -24,16 +23,11 @@ const LandingLayout = async (props: {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||||
|
|
||||||
if (projects.length !== 0) {
|
if (workspaces.length !== 0) {
|
||||||
const firstProject = projects[0];
|
const firstWorkspace = workspaces[0];
|
||||||
const environments = await getEnvironments(firstProject.id);
|
return redirect(`/workspaces/${firstWorkspace.id}/`);
|
||||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
|
||||||
|
|
||||||
if (prodEnvironment) {
|
|
||||||
return redirect(`/environments/${prodEnvironment.id}/`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -26,26 +25,25 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
const { isMember } = getAccessFlags(membership?.role);
|
const isMembershipPending = membership?.role === undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
<LandingSidebar user={user} organization={organization} />
|
<LandingSidebar user={user} organization={organization} isMultiOrgEnabled={isMultiOrgEnabled} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
|
||||||
<ProjectAndOrgSwitch
|
<WorkspaceAndOrgSwitch
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
currentOrganizationName={organization.name}
|
currentOrganizationName={organization.name}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={0}
|
organizationWorkspacesLimit={0}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isLicenseActive={false}
|
isLicenseActive={false}
|
||||||
isOwnerOrManager={false}
|
isOwnerOrManager={false}
|
||||||
isAccessControlAllowed={false}
|
isAccessControlAllowed={false}
|
||||||
isMember={isMember}
|
isMembershipPending={isMembershipPending}
|
||||||
environments={[]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
|||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const ProjectOnboardingLayout = async (props: {
|
const WorkspaceOnboardingLayout = async (props: {
|
||||||
params: Promise<{ organizationId: string }>;
|
params: Promise<{ organizationId: string }>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||||
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectOnboardingLayout;
|
export default WorkspaceOnboardingLayout;
|
||||||
|
|||||||
+14
-3
@@ -2,7 +2,8 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { capturePostHogEvent } from "@/lib/posthog";
|
||||||
|
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -39,7 +40,17 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
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 (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
@@ -48,7 +59,7 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||||
/>
|
/>
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{workspaces.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
+8
-7
@@ -1,12 +1,13 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
|
||||||
const OnboardingLayout = async (props: {
|
const OnboardingLayout = async (props: {
|
||||||
params: Promise<{ organizationId: string }>;
|
params: Promise<{ organizationId: string }>;
|
||||||
@@ -28,15 +29,15 @@ const OnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
|
||||||
getOrganizationProjectsLimit(organization.id),
|
getOrganizationWorkspacesLimit(organization.id),
|
||||||
getOrganizationProjectsCount(organization.id),
|
getOrganizationWorkspacesCount(organization.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
|
||||||
return redirect(`/`);
|
return redirect(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -2,7 +2,8 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||||
|
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -23,6 +24,13 @@ const Page = async (props: ModePageProps) => {
|
|||||||
return redirect(`/auth/login`);
|
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 t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
@@ -39,13 +47,13 @@ const Page = async (props: ModePageProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{workspaces.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
+5
-10
@@ -1,22 +1,17 @@
|
|||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
|
||||||
|
|
||||||
interface SelectPlanOnboardingProps {
|
interface SelectPlanOnboardingProps {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
variant: TPlanVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
|
export const SelectPlanOnboarding = ({ organizationId, variant }: Readonly<SelectPlanOnboardingProps>) => {
|
||||||
const t = await getTranslate();
|
|
||||||
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
|
||||||
<Header
|
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} variant={variant} />
|
||||||
title={t("environments.settings.billing.select_plan_header_title")}
|
|
||||||
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
|
|
||||||
/>
|
|
||||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+21
-1
@@ -1,11 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
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 { 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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||||
|
|
||||||
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||||
|
const VALID_VARIANTS = new Set<TPlanVariant>(PLAN_VARIANTS);
|
||||||
|
|
||||||
interface PlanPageProps {
|
interface PlanPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -36,7 +39,24 @@ const Page = async (props: PlanPageProps) => {
|
|||||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
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} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
+36
-40
@@ -8,19 +8,20 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
TProjectConfigChannel,
|
TWorkspaceConfigChannel,
|
||||||
TProjectConfigIndustry,
|
TWorkspaceConfigIndustry,
|
||||||
TProjectMode,
|
TWorkspaceMode,
|
||||||
TProjectUpdateInput,
|
TWorkspaceUpdateInput,
|
||||||
ZProjectUpdateInput,
|
ZWorkspaceUpdateInput,
|
||||||
} from "@formbricks/types/project";
|
} from "@formbricks/types/workspace";
|
||||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
||||||
import { previewSurvey } from "@/app/lib/templates";
|
import { previewSurvey } from "@/app/lib/templates";
|
||||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||||
|
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
|
||||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||||
|
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
||||||
import {
|
import {
|
||||||
@@ -36,34 +37,34 @@ import { Input } from "@/modules/ui/components/input";
|
|||||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||||
|
|
||||||
interface ProjectSettingsProps {
|
interface WorkspaceSettingsProps {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
projectMode: TProjectMode;
|
workspaceMode: TWorkspaceMode;
|
||||||
channel: TProjectConfigChannel;
|
channel: TWorkspaceConfigChannel;
|
||||||
industry: TProjectConfigIndustry;
|
industry: TWorkspaceConfigIndustry;
|
||||||
defaultBrandColor: string;
|
defaultBrandColor: string;
|
||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userProjectsCount: number;
|
userWorkspacesCount: number;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectSettings = ({
|
export const WorkspaceSettings = ({
|
||||||
organizationId,
|
organizationId,
|
||||||
projectMode,
|
workspaceMode,
|
||||||
channel,
|
channel,
|
||||||
industry,
|
industry,
|
||||||
defaultBrandColor,
|
defaultBrandColor,
|
||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
isAccessControlAllowed = false,
|
||||||
userProjectsCount,
|
userWorkspacesCount,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
}: ProjectSettingsProps) => {
|
}: WorkspaceSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const addProject = async (data: TProjectUpdateInput) => {
|
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
|
||||||
try {
|
try {
|
||||||
// Build the full styling from the chosen brand color so all derived
|
// Build the full styling from the chosen brand color so all derived
|
||||||
// colours (question, button, input, option, progress, etc.) are persisted.
|
// colours (question, button, input, option, progress, etc.) are persisted.
|
||||||
@@ -71,7 +72,7 @@ export const ProjectSettings = ({
|
|||||||
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
|
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
|
||||||
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
|
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
|
||||||
|
|
||||||
const createProjectResponse = await createProjectAction({
|
const createWorkspaceResponse = await createWorkspaceAction({
|
||||||
organizationId,
|
organizationId,
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
@@ -81,26 +82,21 @@ export const ProjectSettings = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createProjectResponse?.data) {
|
if (createWorkspaceResponse?.data) {
|
||||||
// get production environment
|
if (globalThis.window !== undefined) {
|
||||||
const productionEnvironment = createProjectResponse.data.environments.find(
|
// Remove filters when creating a new workspace
|
||||||
(environment) => environment.type === "production"
|
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||||
);
|
|
||||||
if (productionEnvironment) {
|
|
||||||
if (globalThis.window !== undefined) {
|
|
||||||
// Rmove filters when creating a new project
|
|
||||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const workspaceId = createWorkspaceResponse.data.id;
|
||||||
if (channel === "app" || channel === "website") {
|
if (channel === "app" || channel === "website") {
|
||||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
router.push(`/workspaces/${workspaceId}/connect`);
|
||||||
} else if (channel === "link") {
|
} else if (channel === "link") {
|
||||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
router.push(`/workspaces/${workspaceId}/surveys`);
|
||||||
} else if (projectMode === "cx") {
|
} else if (workspaceMode === "cx") {
|
||||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
router.push(`/workspaces/${workspaceId}/xm-templates`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(createProjectResponse);
|
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -109,15 +105,15 @@ export const ProjectSettings = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm<TProjectUpdateInput>({
|
const form = useForm<TWorkspaceUpdateInput>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||||
teamIds: [],
|
teamIds: [],
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZProjectUpdateInput),
|
resolver: zodResolver(ZWorkspaceUpdateInput),
|
||||||
});
|
});
|
||||||
const projectName = form.watch("name");
|
const workspaceName = form.watch("name");
|
||||||
const logoUrl = form.watch("logo.url");
|
const logoUrl = form.watch("logo.url");
|
||||||
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
||||||
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
|
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
|
||||||
@@ -132,7 +128,7 @@ export const ProjectSettings = ({
|
|||||||
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
||||||
<div className="flex w-1/2 flex-col space-y-4">
|
<div className="flex w-1/2 flex-col space-y-4">
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
|
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="styling.brandColor.light"
|
name="styling.brandColor.light"
|
||||||
@@ -184,7 +180,7 @@ export const ProjectSettings = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAccessControlAllowed && userProjectsCount > 0 && (
|
{isAccessControlAllowed && userWorkspacesCount > 0 && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamIds"
|
name="teamIds"
|
||||||
@@ -242,7 +238,7 @@ export const ProjectSettings = ({
|
|||||||
<SurveyInline
|
<SurveyInline
|
||||||
appUrl={publicDomain}
|
appUrl={publicDomain}
|
||||||
isPreviewMode={true}
|
isPreviewMode={true}
|
||||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))}
|
||||||
styling={previewStyling}
|
styling={previewStyling}
|
||||||
isBrandingEnabled={false}
|
isBrandingEnabled={false}
|
||||||
languageCode="default"
|
languageCode="default"
|
||||||
+56
-14
@@ -1,30 +1,39 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import {
|
||||||
|
TWorkspaceConfigChannel,
|
||||||
|
TWorkspaceConfigIndustry,
|
||||||
|
TWorkspaceMode,
|
||||||
|
} from "@formbricks/types/workspace";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
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 { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||||
|
|
||||||
interface ProjectSettingsPageProps {
|
interface WorkspaceSettingsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}>;
|
}>;
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
channel?: TProjectConfigChannel;
|
channel?: TWorkspaceConfigChannel;
|
||||||
industry?: TProjectConfigIndustry;
|
industry?: TWorkspaceConfigIndustry;
|
||||||
mode?: TProjectMode;
|
mode?: TWorkspaceMode;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = async (props: ProjectSettingsPageProps) => {
|
const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
@@ -38,36 +47,69 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
const channel = searchParams.channel ?? null;
|
const channel = searchParams.channel ?? null;
|
||||||
const industry = searchParams.industry ?? null;
|
const industry = searchParams.industry ?? null;
|
||||||
const mode = searchParams.mode ?? "surveys";
|
const mode = searchParams.mode ?? "surveys";
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
|
||||||
|
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 organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||||
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new ResourceNotFoundError(t("common.team"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
|
if (searchParams.mode === "cx") {
|
||||||
|
capturePostHogEvent(
|
||||||
|
session.user.id,
|
||||||
|
"organization_mode_selected",
|
||||||
|
{
|
||||||
|
organization_id: params.organizationId,
|
||||||
|
mode: "cx",
|
||||||
|
},
|
||||||
|
{ organizationId: params.organizationId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||||
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||||
/>
|
/>
|
||||||
<ProjectSettings
|
<WorkspaceSettings
|
||||||
organizationId={params.organizationId}
|
organizationId={params.organizationId}
|
||||||
projectMode={mode}
|
workspaceMode={mode}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
industry={industry}
|
industry={industry}
|
||||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
userProjectsCount={projects.length}
|
userWorkspacesCount={workspaces.length}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{workspaces.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
+10
-13
@@ -4,21 +4,20 @@ import { ArrowRight } from "lucide-react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||||
|
|
||||||
interface ConnectWithFormbricksProps {
|
interface ConnectWithFormbricksProps {
|
||||||
environment: TEnvironment;
|
workspaceId: string;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
appSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
channel: TProjectConfigChannel;
|
channel: TWorkspaceConfigChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConnectWithFormbricks = ({
|
export const ConnectWithFormbricks = ({
|
||||||
environment,
|
workspaceId,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
appSetupCompleted,
|
appSetupCompleted,
|
||||||
channel,
|
channel,
|
||||||
@@ -26,7 +25,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleFinishOnboarding = async () => {
|
const handleFinishOnboarding = async () => {
|
||||||
router.push(`/environments/${environment.id}/surveys`);
|
router.push(`/workspaces/${workspaceId}/surveys`);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,7 +47,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
<div className="flex w-full space-x-10">
|
<div className="flex w-full space-x-10">
|
||||||
<div className="flex w-1/2 flex-col space-y-4">
|
<div className="flex w-1/2 flex-col space-y-4">
|
||||||
<OnboardingSetupInstructions
|
<OnboardingSetupInstructions
|
||||||
environmentId={environment.id}
|
workspaceId={workspaceId}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
appSetupCompleted={appSetupCompleted}
|
appSetupCompleted={appSetupCompleted}
|
||||||
@@ -61,9 +60,9 @@ export const ConnectWithFormbricks = ({
|
|||||||
)}>
|
)}>
|
||||||
{appSetupCompleted ? (
|
{appSetupCompleted ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-3xl">{t("environments.connect.congrats")}</p>
|
<p className="text-3xl">{t("workspace.connect.congrats")}</p>
|
||||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||||
{t("environments.connect.connection_successful_message")}
|
{t("workspace.connect.connection_successful_message")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -73,7 +72,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||||
{t("environments.connect.waiting_for_your_signal")}
|
{t("workspace.connect.waiting_for_your_signal")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -83,9 +82,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
id="finishOnboarding"
|
id="finishOnboarding"
|
||||||
variant={appSetupCompleted ? "default" : "ghost"}
|
variant={appSetupCompleted ? "default" : "ghost"}
|
||||||
onClick={handleFinishOnboarding}>
|
onClick={handleFinishOnboarding}>
|
||||||
{appSetupCompleted
|
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
|
||||||
? t("environments.connect.finish_onboarding")
|
|
||||||
: t("environments.connect.do_it_later")}
|
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
+19
-19
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||||
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
||||||
@@ -17,14 +17,14 @@ const tabs = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface OnboardingSetupInstructionsProps {
|
interface OnboardingSetupInstructionsProps {
|
||||||
environmentId: string;
|
workspaceId: string;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
channel: TProjectConfigChannel;
|
channel: TWorkspaceConfigChannel;
|
||||||
appSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingSetupInstructions = ({
|
export const OnboardingSetupInstructions = ({
|
||||||
environmentId,
|
workspaceId,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
channel,
|
channel,
|
||||||
appSetupCompleted,
|
appSetupCompleted,
|
||||||
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var appUrl = "${publicDomain}";
|
var appUrl = "${publicDomain}";
|
||||||
var environmentId = "${environmentId}";
|
var workspaceId = "${workspaceId}";
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
</script>
|
</script>
|
||||||
<!-- END Formbricks Surveys -->
|
<!-- END Formbricks Surveys -->
|
||||||
`;
|
`;
|
||||||
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var appUrl = "${publicDomain}";
|
var appUrl = "${publicDomain}";
|
||||||
var environmentId = "${environmentId}";
|
var workspaceId = "${workspaceId}";
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
</script>
|
</script>
|
||||||
<!-- END Formbricks Surveys -->
|
<!-- END Formbricks Surveys -->
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const npmSnippetForAppSurveys = `
|
const npmSnippetForAppSurveys = `
|
||||||
import formbricks from "@formbricks/js";
|
import formbricks from "@formbricks/js";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.setup({
|
formbricks.setup({
|
||||||
environmentId: "${environmentId}",
|
workspaceId: "${workspaceId}",
|
||||||
appUrl: "${publicDomain}",
|
appUrl: "${publicDomain}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// your own app
|
// your own app
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const npmSnippetForWebsiteSurveys = `
|
const npmSnippetForWebsiteSurveys = `
|
||||||
// other imports
|
// other imports
|
||||||
import formbricks from "@formbricks/js";
|
import formbricks from "@formbricks/js";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.setup({
|
formbricks.setup({
|
||||||
environmentId: "${environmentId}",
|
workspaceId: "${workspaceId}",
|
||||||
appUrl: "${publicDomain}",
|
appUrl: "${publicDomain}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// your own app
|
// your own app
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
yarn add @formbricks/js
|
yarn add @formbricks/js
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
<p className="text-sm text-slate-700">
|
<p className="text-sm text-slate-700">
|
||||||
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
|
{t("workspace.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
|
||||||
</p>
|
</p>
|
||||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||||
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
|
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
|
||||||
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
) : activeTab === "html" ? (
|
) : activeTab === "html" ? (
|
||||||
<div className="prose prose-slate">
|
<div className="prose prose-slate">
|
||||||
<p className="-mb-1 mt-6 text-sm text-slate-700">
|
<p className="-mb-1 mt-6 text-sm text-slate-700">
|
||||||
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
{t("workspace.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||||
+12
-17
@@ -1,55 +1,50 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getWorkspace } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
interface ConnectPageProps {
|
interface ConnectPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
environmentId: string;
|
workspaceId: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = async (props: ConnectPageProps) => {
|
const Page = async (props: ConnectPageProps) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const environment = await getEnvironment(params.environmentId);
|
|
||||||
|
|
||||||
if (!environment) {
|
const workspace = await getWorkspace(params.workspaceId);
|
||||||
throw new Error(t("common.environment_not_found"));
|
if (!workspace) {
|
||||||
|
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const channel = workspace.config.channel || null;
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.workspace_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||||
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} />
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<p className="text-4xl font-medium text-slate-800"></p>
|
<p className="text-4xl font-medium text-slate-800"></p>
|
||||||
<p className="text-sm text-slate-500"></p>
|
<p className="text-sm text-slate-500"></p>
|
||||||
</div>
|
</div>
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
workspaceId={params.workspaceId}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
appSetupCompleted={environment.appSetupCompleted}
|
appSetupCompleted={workspace.appSetupCompleted}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}`}>
|
<Link href={`/workspaces/${params.workspaceId}`}>
|
||||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const OnboardingLayout = async (props: {
|
const OnboardingLayout = async (props: {
|
||||||
params: Promise<{ environmentId: string }>;
|
params: Promise<{ workspaceId: string }>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
|
|||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
throw new AuthorizationError("User is not authorized to access this environment");
|
throw new AuthorizationError("User is not authorized to access this workspace");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||||
+21
-21
@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
import { TWorkspace } from "@formbricks/types/workspace";
|
||||||
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
|
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
|
||||||
|
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||||
|
|
||||||
interface XMTemplateListProps {
|
interface XMTemplateListProps {
|
||||||
project: TProject;
|
workspace: TWorkspace;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
environmentId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
|
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
|
||||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
|
|||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
};
|
};
|
||||||
const createSurveyResponse = await createSurveyAction({
|
const createSurveyResponse = await createSurveyAction({
|
||||||
environmentId: environmentId,
|
workspaceId: workspaceId,
|
||||||
surveyBody: augmentedTemplate,
|
surveyBody: augmentedTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createSurveyResponse?.data) {
|
if (createSurveyResponse?.data) {
|
||||||
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
|
|||||||
const handleTemplateClick = (templateIdx: number) => {
|
const handleTemplateClick = (templateIdx: number) => {
|
||||||
setActiveTemplateId(templateIdx);
|
setActiveTemplateId(templateIdx);
|
||||||
const template = getXMTemplates(t)[templateIdx];
|
const template = getXMTemplates(t)[templateIdx];
|
||||||
const newTemplate = replacePresetPlaceholders(template, project);
|
const newTemplate = replacePresetPlaceholders(template, workspace);
|
||||||
createSurvey(newTemplate);
|
createSurvey(newTemplate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const XMTemplateOptions = [
|
const XMTemplateOptions = [
|
||||||
{
|
{
|
||||||
title: t("environments.xm-templates.nps"),
|
title: t("workspace.xm-templates.nps"),
|
||||||
description: t("environments.xm-templates.nps_description"),
|
description: t("workspace.xm-templates.nps_description"),
|
||||||
icon: ShoppingCartIcon,
|
icon: ShoppingCartIcon,
|
||||||
onClick: () => handleTemplateClick(0),
|
onClick: () => handleTemplateClick(0),
|
||||||
isLoading: activeTemplateId === 0,
|
isLoading: activeTemplateId === 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.xm-templates.five_star_rating"),
|
title: t("workspace.xm-templates.five_star_rating"),
|
||||||
description: t("environments.xm-templates.five_star_rating_description"),
|
description: t("workspace.xm-templates.five_star_rating_description"),
|
||||||
icon: StarIcon,
|
icon: StarIcon,
|
||||||
onClick: () => handleTemplateClick(1),
|
onClick: () => handleTemplateClick(1),
|
||||||
isLoading: activeTemplateId === 1,
|
isLoading: activeTemplateId === 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.xm-templates.csat"),
|
title: t("workspace.xm-templates.csat"),
|
||||||
description: t("environments.xm-templates.csat_description"),
|
description: t("workspace.xm-templates.csat_description"),
|
||||||
icon: ThumbsUpIcon,
|
icon: ThumbsUpIcon,
|
||||||
onClick: () => handleTemplateClick(2),
|
onClick: () => handleTemplateClick(2),
|
||||||
isLoading: activeTemplateId === 2,
|
isLoading: activeTemplateId === 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.xm-templates.ces"),
|
title: t("workspace.xm-templates.ces"),
|
||||||
description: t("environments.xm-templates.ces_description"),
|
description: t("workspace.xm-templates.ces_description"),
|
||||||
icon: ActivityIcon,
|
icon: ActivityIcon,
|
||||||
onClick: () => handleTemplateClick(3),
|
onClick: () => handleTemplateClick(3),
|
||||||
isLoading: activeTemplateId === 3,
|
isLoading: activeTemplateId === 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.xm-templates.smileys"),
|
title: t("workspace.xm-templates.smileys"),
|
||||||
description: t("environments.xm-templates.smileys_description"),
|
description: t("workspace.xm-templates.smileys_description"),
|
||||||
icon: SmileIcon,
|
icon: SmileIcon,
|
||||||
onClick: () => handleTemplateClick(4),
|
onClick: () => handleTemplateClick(4),
|
||||||
isLoading: activeTemplateId === 4,
|
isLoading: activeTemplateId === 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.xm-templates.enps"),
|
title: t("workspace.xm-templates.enps"),
|
||||||
description: t("environments.xm-templates.enps_description"),
|
description: t("workspace.xm-templates.enps_description"),
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
onClick: () => handleTemplateClick(5),
|
onClick: () => handleTemplateClick(5),
|
||||||
isLoading: activeTemplateId === 5,
|
isLoading: activeTemplateId === 5,
|
||||||
+16
-16
@@ -1,17 +1,17 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
import { TWorkspace } from "@formbricks/types/workspace";
|
||||||
import { replacePresetPlaceholders } from "./utils";
|
import { replacePresetPlaceholders } from "./utils";
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const mockProject: TProject = {
|
const mockWorkspace: TWorkspace = {
|
||||||
id: "project1",
|
id: "workspace1",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
name: "Test Project",
|
name: "Test Workspace",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
styling: {
|
styling: {
|
||||||
allowStyleOverwrite: true,
|
allowStyleOverwrite: true,
|
||||||
@@ -27,12 +27,12 @@ const mockProject: TProject = {
|
|||||||
placement: "bottomRight",
|
placement: "bottomRight",
|
||||||
clickOutsideClose: true,
|
clickOutsideClose: true,
|
||||||
overlay: "none",
|
overlay: "none",
|
||||||
environments: [],
|
appSetupCompleted: false,
|
||||||
languages: [],
|
languages: [],
|
||||||
logo: null,
|
logo: null,
|
||||||
};
|
};
|
||||||
const mockTemplate: TXMTemplate = {
|
const mockTemplate: TXMTemplate = {
|
||||||
name: "$[projectName] Survey",
|
name: "$[workspaceName] Survey",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "block1",
|
id: "block1",
|
||||||
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
|
|||||||
id: "q1",
|
id: "q1",
|
||||||
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
||||||
inputType: "text" as const,
|
inputType: "text" as const,
|
||||||
headline: { default: "$[projectName] Question" },
|
headline: { default: "$[workspaceName] Question" },
|
||||||
subheader: { default: "" },
|
subheader: { default: "" },
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: { default: "" },
|
placeholder: { default: "" },
|
||||||
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
|
|||||||
],
|
],
|
||||||
styling: {
|
styling: {
|
||||||
brandColor: { light: "#0000FF" },
|
brandColor: { light: "#0000FF" },
|
||||||
questionColor: { light: "#00FF00" },
|
elementHeadlineColor: { light: "#00FF00" },
|
||||||
inputColor: { light: "#FF0000" },
|
inputBgColor: { light: "#FF0000" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces projectName placeholder in template name", () => {
|
test("replaces workspaceName placeholder in template name", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||||
expect(result.name).toBe("Test Project Survey");
|
expect(result.name).toBe("Test Workspace Survey");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces projectName placeholder in element headline", () => {
|
test("replaces workspaceName placeholder in element headline", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a new object without mutating the original template", () => {
|
test("returns a new object without mutating the original template", () => {
|
||||||
const originalTemplate = structuredClone(mockTemplate);
|
const originalTemplate = structuredClone(mockTemplate);
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||||
expect(result).not.toBe(mockTemplate);
|
expect(result).not.toBe(mockTemplate);
|
||||||
expect(mockTemplate).toEqual(originalTemplate);
|
expect(mockTemplate).toEqual(originalTemplate);
|
||||||
});
|
});
|
||||||
+5
-5
@@ -1,16 +1,16 @@
|
|||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
import { TWorkspace } from "@formbricks/types/workspace";
|
||||||
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
||||||
|
|
||||||
// replace all occurences of projectName with the actual project name in the current template
|
// replace all occurences of workspaceName with the actual workspace name in the current template
|
||||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
|
||||||
const survey = structuredClone(template);
|
const survey = structuredClone(template);
|
||||||
|
|
||||||
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
||||||
...block,
|
...block,
|
||||||
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
|
interface XMTemplatePageProps {
|
||||||
|
params: Promise<{
|
||||||
|
workspaceId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async (props: XMTemplatePageProps) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const t = await getTranslate();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await getWorkspace(params.workspaceId);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
|
<Header title={t("workspace.xm-templates.headline")} />
|
||||||
|
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
|
||||||
|
{workspaces.length >= 2 && (
|
||||||
|
<Button
|
||||||
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
|
variant="ghost"
|
||||||
|
asChild>
|
||||||
|
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
|
||||||
|
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props: {
|
|
||||||
params: Promise<{ environmentId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const { children } = props;
|
|
||||||
|
|
||||||
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return redirect(`/auth/login`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error(t("common.user_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const environment = await getEnvironment(params.environmentId);
|
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col">
|
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SurveyEditorEnvironmentLayout;
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { getWorkspace } from "@/lib/workspace/service";
|
||||||
|
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
|
||||||
|
|
||||||
|
const SurveyEditorWorkspaceLayout = async (props: {
|
||||||
|
params: Promise<{ workspaceId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect(`/auth/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await getWorkspace(params.workspaceId);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SurveyEditorWorkspaceLayout;
|
||||||
@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Confetti } from "@/modules/ui/components/confetti";
|
import { Confetti } from "@/modules/ui/components/confetti";
|
||||||
|
|
||||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
|
||||||
|
|
||||||
export const ConfirmationPage = () => {
|
export const ConfirmationPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
|
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowConfetti(true);
|
setShowConfetti(true);
|
||||||
@@ -20,11 +20,9 @@ export const ConfirmationPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
|
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
|
||||||
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
|
if (storedWorkspaceId) {
|
||||||
);
|
setResolvedWorkspaceId(storedWorkspaceId);
|
||||||
if (storedEnvironmentId) {
|
|
||||||
setResolvedEnvironmentId(storedEnvironmentId);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -43,9 +41,7 @@ export const ConfirmationPage = () => {
|
|||||||
<Button asChild className="w-full justify-center">
|
<Button asChild className="w-full justify-center">
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
resolvedEnvironmentId
|
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/"
|
||||||
? `/environments/${resolvedEnvironmentId}/settings/billing`
|
|
||||||
: "/environments"
|
|
||||||
}>
|
}>
|
||||||
{t("billing_confirmation.back_to_billing_overview")}
|
{t("billing_confirmation.back_to_billing_overview")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
export const LoadingCard = ({
|
export const LoadingCard = ({
|
||||||
|
|||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
|
||||||
|
|
||||||
interface EnvironmentStorageHandlerProps {
|
|
||||||
environmentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
|
|
||||||
}, [environmentId]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EnvironmentStorageHandler;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { Label } from "@/modules/ui/components/label";
|
|
||||||
import { Switch } from "@/modules/ui/components/switch";
|
|
||||||
|
|
||||||
interface EnvironmentSwitchProps {
|
|
||||||
environment: TEnvironment;
|
|
||||||
environments: TEnvironment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleEnvironmentChange = (environmentType: "production" | "development") => {
|
|
||||||
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
|
|
||||||
if (newEnvironmentId) {
|
|
||||||
router.push(`/environments/${newEnvironmentId}/`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEnvSwitch = () => {
|
|
||||||
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
|
|
||||||
setIsLoading(true);
|
|
||||||
setIsEnvSwitchChecked(!isEnvSwitchChecked);
|
|
||||||
handleEnvironmentChange(newEnvironmentType);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center space-x-2 rounded-lg p-2",
|
|
||||||
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
|
|
||||||
)}>
|
|
||||||
<Label
|
|
||||||
htmlFor="development-mode"
|
|
||||||
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
|
|
||||||
{t("common.dev_env")}
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
|
|
||||||
id="development-mode"
|
|
||||||
disabled={isLoading}
|
|
||||||
checked={isEnvSwitchChecked}
|
|
||||||
onCheckedChange={toggleEnvSwitch}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowUpRightIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
Cog,
|
|
||||||
LogOutIcon,
|
|
||||||
MessageCircle,
|
|
||||||
PanelLeftCloseIcon,
|
|
||||||
PanelLeftOpenIcon,
|
|
||||||
RocketIcon,
|
|
||||||
UserCircleIcon,
|
|
||||||
UserIcon,
|
|
||||||
WorkflowIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
|
||||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
|
||||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
|
||||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import packageJson from "../../../../../package.json";
|
|
||||||
|
|
||||||
interface NavigationProps {
|
|
||||||
environment: TEnvironment;
|
|
||||||
user: TUser;
|
|
||||||
organization: TOrganization;
|
|
||||||
project: { id: string; name: string };
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
isDevelopment: boolean;
|
|
||||||
membershipRole?: TOrganizationRole;
|
|
||||||
publicDomain: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MainNavigation = ({
|
|
||||||
environment,
|
|
||||||
organization,
|
|
||||||
user,
|
|
||||||
project,
|
|
||||||
membershipRole,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isDevelopment,
|
|
||||||
publicDomain,
|
|
||||||
}: NavigationProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
|
||||||
const [latestVersion, setLatestVersion] = useState("");
|
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
|
||||||
|
|
||||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
setIsCollapsed(!isCollapsed);
|
|
||||||
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
|
|
||||||
setIsCollapsed(isCollapsedValueFromLocalStorage);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const toggleTextOpacity = () => {
|
|
||||||
setIsTextVisible(isCollapsed);
|
|
||||||
};
|
|
||||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [isCollapsed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Auto collapse project navbar on org and account settings
|
|
||||||
if (pathname?.includes("/settings")) {
|
|
||||||
setIsCollapsed(true);
|
|
||||||
}
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
const mainNavigation = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
name: t("common.surveys"),
|
|
||||||
href: `/environments/${environment.id}/surveys`,
|
|
||||||
icon: MessageCircle,
|
|
||||||
isActive: pathname?.includes("/surveys"),
|
|
||||||
isHidden: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: `/environments/${environment.id}/contacts`,
|
|
||||||
name: t("common.contacts"),
|
|
||||||
icon: UserIcon,
|
|
||||||
isActive:
|
|
||||||
pathname?.includes("/contacts") ||
|
|
||||||
pathname?.includes("/segments") ||
|
|
||||||
pathname?.includes("/attributes"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("common.workflows"),
|
|
||||||
href: `/environments/${environment.id}/workflows`,
|
|
||||||
icon: WorkflowIcon,
|
|
||||||
isActive: pathname?.includes("/workflows"),
|
|
||||||
isHidden: !isFormbricksCloud,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("common.configuration"),
|
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
|
||||||
icon: Cog,
|
|
||||||
isActive: pathname?.includes("/workspace"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t, environment.id, pathname, isFormbricksCloud]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownNavigation = [
|
|
||||||
{
|
|
||||||
label: t("common.account"),
|
|
||||||
href: `/environments/${environment.id}/settings/profile`,
|
|
||||||
icon: UserCircleIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("common.documentation"),
|
|
||||||
href: "https://formbricks.com/docs",
|
|
||||||
target: "_blank",
|
|
||||||
icon: ArrowUpRightIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("common.share_feedback"),
|
|
||||||
href: "https://github.com/formbricks/formbricks/issues",
|
|
||||||
target: "_blank",
|
|
||||||
icon: ArrowUpRightIcon,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadReleases() {
|
|
||||||
const res = await getLatestStableFbReleaseAction();
|
|
||||||
if (res?.data) {
|
|
||||||
const latestVersionTag = res.data;
|
|
||||||
const currentVersionTag = `v${packageJson.version}`;
|
|
||||||
|
|
||||||
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
|
||||||
setLatestVersion(latestVersionTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isOwnerOrManager) loadReleases();
|
|
||||||
}, [isOwnerOrManager]);
|
|
||||||
|
|
||||||
const trialDaysRemaining = useMemo(() => {
|
|
||||||
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
|
||||||
const trialEnd = organization.billing.stripe.trialEnd;
|
|
||||||
if (!trialEnd) return null;
|
|
||||||
const ts = new Date(trialEnd).getTime();
|
|
||||||
if (!Number.isFinite(ts)) return null;
|
|
||||||
const msPerDay = 86_400_000;
|
|
||||||
return Math.ceil((ts - Date.now()) / msPerDay);
|
|
||||||
}, [
|
|
||||||
isFormbricksCloud,
|
|
||||||
organization.billing?.stripe?.subscriptionStatus,
|
|
||||||
organization.billing?.stripe?.trialEnd,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{project && (
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
|
||||||
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
|
||||||
)}>
|
|
||||||
<div>
|
|
||||||
{/* Logo and Toggle */}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-3 pb-4">
|
|
||||||
{!isCollapsed && (
|
|
||||||
<Link
|
|
||||||
href={mainNavigationLink}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center transition-opacity duration-100",
|
|
||||||
isTextVisible ? "opacity-0" : "opacity-100"
|
|
||||||
)}>
|
|
||||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
|
||||||
)}>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
|
||||||
) : (
|
|
||||||
<PanelLeftCloseIcon strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Nav Switch */}
|
|
||||||
{!isBilling && (
|
|
||||||
<ul>
|
|
||||||
{mainNavigation.map(
|
|
||||||
(item) =>
|
|
||||||
!item.isHidden && (
|
|
||||||
<NavigationLink
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
isActive={item.isActive}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
isTextVisible={isTextVisible}
|
|
||||||
linkText={item.name}>
|
|
||||||
<item.icon strokeWidth={1.5} />
|
|
||||||
</NavigationLink>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{/* New Version Available */}
|
|
||||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
|
||||||
<Link
|
|
||||||
href="https://github.com/formbricks/formbricks/releases"
|
|
||||||
target="_blank"
|
|
||||||
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
|
|
||||||
<p className="flex items-center justify-center gap-x-2 text-xs">
|
|
||||||
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
|
|
||||||
{t("common.new_version_available", { version: latestVersion })}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Trial Days Remaining */}
|
|
||||||
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
|
||||||
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
|
||||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Switch */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
asChild
|
|
||||||
id="userDropdownTrigger"
|
|
||||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-pointer flex-row items-center gap-3",
|
|
||||||
isCollapsed ? "justify-center px-2" : "px-4"
|
|
||||||
)}>
|
|
||||||
<ProfileAvatar userId={user.id} />
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
|
||||||
<p
|
|
||||||
title={user?.email}
|
|
||||||
className={cn(
|
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
|
||||||
)}>
|
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon
|
|
||||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
|
||||||
id="userDropdownInnerContentWrapper"
|
|
||||||
side="right"
|
|
||||||
sideOffset={10}
|
|
||||||
alignOffset={5}
|
|
||||||
align="end">
|
|
||||||
{/* Dropdown Items */}
|
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
|
||||||
<Link
|
|
||||||
href={link.href}
|
|
||||||
target={link.target}
|
|
||||||
className="flex w-full items-center"
|
|
||||||
key={link.label}
|
|
||||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{link.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{/* Logout */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={async () => {
|
|
||||||
const loginUrl = `${publicDomain}/auth/login`;
|
|
||||||
const route = await signOutWithAudit({
|
|
||||||
reason: "user_initiated",
|
|
||||||
redirectUrl: loginUrl,
|
|
||||||
organizationId: organization.id,
|
|
||||||
redirect: false,
|
|
||||||
callbackUrl: loginUrl,
|
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
|
||||||
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
|
||||||
}}
|
|
||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
|
||||||
{t("common.logout")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import React from "react";
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
|
||||||
|
|
||||||
interface NavigationLinkProps {
|
|
||||||
href: string;
|
|
||||||
isActive: boolean;
|
|
||||||
isCollapsed: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
linkText: string;
|
|
||||||
isTextVisible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NavigationLink = ({
|
|
||||||
href,
|
|
||||||
isActive,
|
|
||||||
isCollapsed = false,
|
|
||||||
children,
|
|
||||||
linkText,
|
|
||||||
isTextVisible = true,
|
|
||||||
}: NavigationLinkProps) => {
|
|
||||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
|
||||||
const inactiveClass =
|
|
||||||
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<li
|
|
||||||
className={cn(
|
|
||||||
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
|
|
||||||
isActive ? activeClass : inactiveClass
|
|
||||||
)}>
|
|
||||||
<Link href={href} className="flex items-center">
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{linkText}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<li
|
|
||||||
className={cn(
|
|
||||||
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
|
||||||
isActive ? activeClass : inactiveClass
|
|
||||||
)}>
|
|
||||||
<Link href={href} className="flex items-center">
|
|
||||||
{children}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-2 flex transition-opacity duration-100",
|
|
||||||
isTextVisible ? "opacity-0" : "opacity-100"
|
|
||||||
)}>
|
|
||||||
{linkText}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
|
||||||
|
|
||||||
export const EnvironmentBreadcrumb = ({
|
|
||||||
environments,
|
|
||||||
currentEnvironment,
|
|
||||||
}: {
|
|
||||||
environments: { id: string; type: string }[];
|
|
||||||
currentEnvironment: { id: string; type: string };
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleEnvironmentChange = (environmentId: string) => {
|
|
||||||
if (environmentId === currentEnvironment.id) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
router.push(`/environments/${environmentId}/`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const developmentTooltip = () => {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<CircleHelpIcon className="h-3 w-3" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
|
|
||||||
{t("common.development_environment_banner")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbItem
|
|
||||||
isActive={isEnvironmentDropdownOpen}
|
|
||||||
isHighlighted={currentEnvironment.type === "development"}>
|
|
||||||
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
|
||||||
id="environmentDropdownTrigger"
|
|
||||||
asChild>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
<span className="capitalize">{currentEnvironment.type}</span>
|
|
||||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
|
||||||
{currentEnvironment.type === "development" && developmentTooltip()}
|
|
||||||
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="mt-2" align="start">
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<Code2Icon className="mr-2 inline h-4 w-4" />
|
|
||||||
{t("common.choose_environment")}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{environments.map((env) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={env.id}
|
|
||||||
checked={env.type === currentEnvironment.type}
|
|
||||||
onClick={() => handleEnvironmentChange(env.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2 capitalize">
|
|
||||||
<span>{env.type}</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
|
||||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
|
||||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
|
||||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
|
||||||
|
|
||||||
interface ProjectAndOrgSwitchProps {
|
|
||||||
currentOrganizationId: string;
|
|
||||||
currentOrganizationName?: string; // Optional: for pages without context
|
|
||||||
currentProjectId?: string;
|
|
||||||
currentProjectName?: string; // Optional: for pages without context
|
|
||||||
currentEnvironmentId?: string;
|
|
||||||
environments: { id: string; type: string }[];
|
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
isOwnerOrManager: boolean;
|
|
||||||
isMember: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectAndOrgSwitch = ({
|
|
||||||
currentOrganizationId,
|
|
||||||
currentOrganizationName,
|
|
||||||
currentProjectId,
|
|
||||||
currentProjectName,
|
|
||||||
currentEnvironmentId,
|
|
||||||
environments,
|
|
||||||
isMultiOrgEnabled,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isLicenseActive,
|
|
||||||
isOwnerOrManager,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
isMember,
|
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList className="gap-0">
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={currentOrganizationId}
|
|
||||||
currentOrganizationName={currentOrganizationName}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
|
||||||
isMember={isMember}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
|
||||||
/>
|
|
||||||
{currentProjectId && currentEnvironmentId && (
|
|
||||||
<ProjectBreadcrumb
|
|
||||||
currentProjectId={currentProjectId}
|
|
||||||
currentProjectName={currentProjectName}
|
|
||||||
currentOrganizationId={currentOrganizationId}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
|
||||||
isLicenseActive={isLicenseActive}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showEnvironmentBreadcrumb && (
|
|
||||||
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
|
|
||||||
)}
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useState, useTransition } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
|
||||||
import { useProject } from "../context/environment-context";
|
|
||||||
|
|
||||||
interface ProjectBreadcrumbProps {
|
|
||||||
currentProjectId: string;
|
|
||||||
currentProjectName?: string; // Optional: pass directly if context not available
|
|
||||||
isOwnerOrManager: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
currentOrganizationId: string;
|
|
||||||
currentEnvironmentId: string;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
isEnvironmentBreadcrumbVisible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
|
||||||
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
|
||||||
if (pathname.includes("/settings/")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
|
||||||
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
|
||||||
return pattern.test(pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProjectBreadcrumb = ({
|
|
||||||
currentProjectId,
|
|
||||||
currentProjectName,
|
|
||||||
isOwnerOrManager,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isLicenseActive,
|
|
||||||
currentOrganizationId,
|
|
||||||
currentEnvironmentId,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
isEnvironmentBreadcrumbVisible,
|
|
||||||
}: ProjectBreadcrumbProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
|
||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
|
||||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
||||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// Get current project name from context OR prop
|
|
||||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
|
||||||
const { project: currentProject } = useProject();
|
|
||||||
const projectName = currentProject?.name || currentProjectName || "";
|
|
||||||
|
|
||||||
// Lazy-load projects when dropdown opens
|
|
||||||
useEffect(() => {
|
|
||||||
// Only fetch when dropdown opened for first time (and no error state)
|
|
||||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
|
||||||
setIsLoadingProjects(true);
|
|
||||||
setLoadError(null); // Clear any previous errors
|
|
||||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
|
||||||
if (result?.data) {
|
|
||||||
// Sort projects by name
|
|
||||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
setProjects(sorted);
|
|
||||||
} else {
|
|
||||||
// Handle server errors or validation errors
|
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
|
||||||
const error = new Error(errorMessage);
|
|
||||||
logger.error(error, "Failed to load projects");
|
|
||||||
Sentry.captureException(error);
|
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
|
||||||
}
|
|
||||||
setIsLoadingProjects(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
|
||||||
|
|
||||||
const projectSettings = [
|
|
||||||
{
|
|
||||||
id: "general",
|
|
||||||
label: t("common.general"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "look",
|
|
||||||
label: t("common.look_and_feel"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "app-connection",
|
|
||||||
label: t("common.website_and_app_connection"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "integrations",
|
|
||||||
label: t("common.integrations"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.team_access"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "languages",
|
|
||||||
label: t("common.survey_languages"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tags",
|
|
||||||
label: t("common.tags"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!currentProject) {
|
|
||||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
|
||||||
logger.error(errorMessage);
|
|
||||||
Sentry.captureException(new Error(errorMessage));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
|
||||||
if (projectId === currentProjectId) return;
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(`/workspaces/${projectId}/`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddProject = () => {
|
|
||||||
if (projects.length >= organizationProjectsLimit) {
|
|
||||||
setOpenLimitModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpenCreateProjectModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
|
||||||
if (isFormbricksCloud) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: isLicenseActive
|
|
||||||
? `/environments/${currentEnvironmentId}/settings/enterprise`
|
|
||||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
|
||||||
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
|
||||||
id="projectDropdownTrigger"
|
|
||||||
asChild>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
<span>{projectName}</span>
|
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
|
||||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
|
||||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
) : (
|
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.choose_workspace")}
|
|
||||||
</div>
|
|
||||||
{isLoadingProjects && (
|
|
||||||
<div className="flex items-center justify-center py-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects && loadError && (
|
|
||||||
<div className="px-2 py-4">
|
|
||||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setLoadError(null);
|
|
||||||
setProjects([]);
|
|
||||||
}}
|
|
||||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
|
||||||
{t("common.try_again")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects && !loadError && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
|
||||||
{projects.map((proj) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={proj.id}
|
|
||||||
checked={proj.id === currentProjectId}
|
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{proj.name}</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isOwnerOrManager && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={handleAddProject}
|
|
||||||
className="w-full cursor-pointer justify-between">
|
|
||||||
<span>{t("common.add_new_workspace")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.workspace_configuration")}
|
|
||||||
</div>
|
|
||||||
{projectSettings.map((setting) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={setting.id}
|
|
||||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{/* Modals */}
|
|
||||||
{openLimitModal && (
|
|
||||||
<ProjectLimitModal
|
|
||||||
open={openLimitModal}
|
|
||||||
setOpen={setOpenLimitModal}
|
|
||||||
buttons={LimitModalButtons()}
|
|
||||||
projectLimit={organizationProjectsLimit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateProjectModal && (
|
|
||||||
<CreateProjectModal
|
|
||||||
open={openCreateProjectModal}
|
|
||||||
setOpen={setOpenCreateProjectModal}
|
|
||||||
organizationId={currentOrganizationId}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createContext, useContext, useMemo } from "react";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
|
|
||||||
export interface EnvironmentContextType {
|
|
||||||
environment: TEnvironment;
|
|
||||||
project: TProject;
|
|
||||||
organization: TOrganization;
|
|
||||||
organizationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
|
||||||
|
|
||||||
export const useEnvironment = () => {
|
|
||||||
const context = useContext(EnvironmentContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useProject = () => {
|
|
||||||
const context = useContext(EnvironmentContext);
|
|
||||||
if (!context) {
|
|
||||||
return { project: null };
|
|
||||||
}
|
|
||||||
return { project: context.project };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOrganization = () => {
|
|
||||||
const context = useContext(EnvironmentContext);
|
|
||||||
if (!context) {
|
|
||||||
return { organization: null };
|
|
||||||
}
|
|
||||||
return { organization: context.organization };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Client wrapper component to be used in server components
|
|
||||||
interface EnvironmentContextWrapperProps {
|
|
||||||
environment: TEnvironment;
|
|
||||||
project: TProject;
|
|
||||||
organization: TOrganization;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnvironmentContextWrapper = ({
|
|
||||||
environment,
|
|
||||||
project,
|
|
||||||
organization,
|
|
||||||
children,
|
|
||||||
}: EnvironmentContextWrapperProps) => {
|
|
||||||
const environmentContextValue = useMemo(
|
|
||||||
() => ({
|
|
||||||
environment,
|
|
||||||
project,
|
|
||||||
organization,
|
|
||||||
organizationId: project.organizationId,
|
|
||||||
}),
|
|
||||||
[environment, project, organization]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
|
||||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
|
||||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
|
||||||
|
|
||||||
const EnvLayout = async (props: {
|
|
||||||
params: Promise<{ environmentId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const { children } = props;
|
|
||||||
|
|
||||||
// Check session first (required for userId)
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
|
||||||
return redirect(`/auth/login`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
|
||||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
|
||||||
<EnvironmentContextWrapper
|
|
||||||
environment={layoutData.environment}
|
|
||||||
project={layoutData.project}
|
|
||||||
organization={layoutData.organization}>
|
|
||||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EnvLayout;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
|
|
||||||
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
|
||||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
|
||||||
|
|
||||||
if (isBilling) {
|
|
||||||
if (IS_FORMBRICKS_CLOUD) {
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
|
||||||
} else {
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect(`/environments/${params.environmentId}/surveys`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EnvironmentPage;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
|
|
||||||
const AccountSettingsLayout = async (props: {
|
|
||||||
params: Promise<{ environmentId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const { children } = props;
|
|
||||||
|
|
||||||
const t = await getTranslate();
|
|
||||||
const [organization, project, session] = await Promise.all([
|
|
||||||
getOrganizationByEnvironmentId(params.environmentId),
|
|
||||||
getProjectByEnvironmentId(params.environmentId),
|
|
||||||
getServerSession(authOptions),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.workspace_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AccountSettingsLayout;
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
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 { Button } from "@/modules/ui/components/button";
|
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
|
||||||
|
|
||||||
export const DeleteAccount = ({
|
|
||||||
session,
|
|
||||||
IS_FORMBRICKS_CLOUD,
|
|
||||||
user,
|
|
||||||
organizationsWithSingleOwner,
|
|
||||||
isMultiOrgEnabled,
|
|
||||||
}: {
|
|
||||||
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();
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<DeleteAccountModal
|
|
||||||
open={isModalOpen}
|
|
||||||
setOpen={setModalOpen}
|
|
||||||
user={user}
|
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
|
||||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-slate-700">
|
|
||||||
<strong>{t("environments.settings.profile.warning_cannot_undo")}</strong>
|
|
||||||
</p>
|
|
||||||
<TooltipRenderer
|
|
||||||
shouldRender={isDeleteDisabled}
|
|
||||||
tooltipContent={t("environments.settings.profile.warning_cannot_delete_account")}>
|
|
||||||
<Button
|
|
||||||
className="mt-4"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setModalOpen(!isModalOpen)}
|
|
||||||
disabled={isDeleteDisabled}>
|
|
||||||
{t("environments.settings.profile.confirm_delete_my_account")}
|
|
||||||
</Button>
|
|
||||||
</TooltipRenderer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-133
@@ -1,133 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
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: {
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
test("should return false if user exists", async () => {
|
|
||||||
mockPrismaUserFindUnique.mockResolvedValue({
|
|
||||||
id: "some-user-id",
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const result = await getIsEmailUnique(email);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
|
||||||
where: { email },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return true if user does not exist", async () => {
|
|
||||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await getIsEmailUnique(email);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
|
||||||
where: { email },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import Loading from "@/modules/organization/settings/api-keys/loading";
|
|
||||||
|
|
||||||
export default function LoadingPage() {
|
|
||||||
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
|
|
||||||
}
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
|
||||||
|
|
||||||
interface OrganizationSettingsNavbarProps {
|
|
||||||
environmentId?: string;
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
membershipRole?: TOrganizationRole;
|
|
||||||
activeId: string;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrganizationSettingsNavbar = ({
|
|
||||||
environmentId,
|
|
||||||
isFormbricksCloud,
|
|
||||||
membershipRole,
|
|
||||||
activeId,
|
|
||||||
loading,
|
|
||||||
}: OrganizationSettingsNavbarProps) => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
|
||||||
const isPricingDisabled = isMember;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{
|
|
||||||
id: "general",
|
|
||||||
label: t("common.general"),
|
|
||||||
href: `/environments/${environmentId}/settings/general`,
|
|
||||||
current: pathname?.includes("/general"),
|
|
||||||
hidden: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.members_and_teams"),
|
|
||||||
href: `/environments/${environmentId}/settings/teams`,
|
|
||||||
current: pathname?.includes("/teams"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "api-keys",
|
|
||||||
label: t("common.api_keys"),
|
|
||||||
href: `/environments/${environmentId}/settings/api-keys`,
|
|
||||||
current: pathname?.includes("/api-keys"),
|
|
||||||
hidden: !isOwner,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "domain",
|
|
||||||
label: t("common.domain"),
|
|
||||||
href: `/environments/${environmentId}/settings/domain`,
|
|
||||||
current: pathname?.includes("/domain"),
|
|
||||||
hidden: isFormbricksCloud,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "billing",
|
|
||||||
label: t("common.billing"),
|
|
||||||
href: `/environments/${environmentId}/settings/billing`,
|
|
||||||
hidden: !isFormbricksCloud || loading,
|
|
||||||
current: pathname?.includes("/billing"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "enterprise",
|
|
||||||
label: t("common.enterprise_license"),
|
|
||||||
href: `/environments/${environmentId}/settings/enterprise`,
|
|
||||||
hidden: isFormbricksCloud || isPricingDisabled,
|
|
||||||
current: pathname?.includes("/enterprise"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
|
||||||
};
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
|
||||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
|
||||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
|
||||||
|
|
||||||
const ZUpdateOrganizationNameAction = z.object({
|
|
||||||
organizationId: ZId,
|
|
||||||
data: ZOrganizationUpdateInput.pick({ name: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateOrganizationNameAction = authenticatedActionClient
|
|
||||||
.inputSchema(ZUpdateOrganizationNameAction)
|
|
||||||
.action(
|
|
||||||
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
|
||||||
data: parsedInput.data,
|
|
||||||
roles: ["owner"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
|
||||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
|
||||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const ZDeleteOrganizationAction = z.object({
|
|
||||||
organizationId: ZId,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteOrganizationAction = authenticatedActionClient
|
|
||||||
.inputSchema(ZDeleteOrganizationAction)
|
|
||||||
.action(
|
|
||||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
|
||||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
|
||||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
return await deleteOrganization(parsedInput.organizationId);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
|
||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Loading = async () => {
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{
|
|
||||||
title: t("environments.settings.general.organization_name"),
|
|
||||||
description: t("environments.settings.general.organization_name_description"),
|
|
||||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("environments.settings.general.delete_organization"),
|
|
||||||
description: t("environments.settings.general.delete_organization_description"),
|
|
||||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
|
||||||
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="general" loading />
|
|
||||||
</PageHeader>
|
|
||||||
{cards.map((card, index) => (
|
|
||||||
<LoadingCard key={index} {...card} />
|
|
||||||
))}
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Loading;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
|
|
||||||
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const { children } = props;
|
|
||||||
|
|
||||||
const t = await getTranslate();
|
|
||||||
const [organization, project, session] = await Promise.all([
|
|
||||||
getOrganizationByEnvironmentId(params.environmentId),
|
|
||||||
getProjectByEnvironmentId(params.environmentId),
|
|
||||||
getServerSession(authOptions),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.workspace_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { Confetti } from "@/modules/ui/components/confetti";
|
|
||||||
|
|
||||||
interface SummaryMetadataProps {
|
|
||||||
environment: TEnvironment;
|
|
||||||
survey: TSurvey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [confetti, setConfetti] = useState(false);
|
|
||||||
|
|
||||||
const isAppSurvey = survey.type === "app";
|
|
||||||
const appSetupCompleted = environment.appSetupCompleted;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newSurveyParam = searchParams?.get("success");
|
|
||||||
if (newSurveyParam && survey && environment) {
|
|
||||||
setConfetti(true);
|
|
||||||
toast.success(
|
|
||||||
isAppSurvey && !appSetupCompleted
|
|
||||||
? t("environments.surveys.summary.almost_there")
|
|
||||||
: t("environments.surveys.summary.congrats"),
|
|
||||||
{
|
|
||||||
id: "survey-publish-success-toast",
|
|
||||||
icon: isAppSurvey && !appSetupCompleted ? "🤏" : "🎉",
|
|
||||||
duration: 5000,
|
|
||||||
position: "bottom-right",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove success param from url
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete("success");
|
|
||||||
if (survey.type === "link") {
|
|
||||||
// Add share param to url to open share embed modal
|
|
||||||
url.searchParams.set("share", "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
window.history.replaceState({}, "", url.toString());
|
|
||||||
}
|
|
||||||
}, [environment, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
|
|
||||||
|
|
||||||
return <>{confetti && <Confetti />}</>;
|
|
||||||
};
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
|
||||||
import { ZSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
|
||||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
|
||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
|
||||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
|
||||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
|
||||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
|
||||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
|
||||||
|
|
||||||
const ZGetResponsesDownloadUrlAction = z.object({
|
|
||||||
surveyId: ZId,
|
|
||||||
format: z.union([z.literal("csv"), z.literal("xlsx")]),
|
|
||||||
filterCriteria: ZResponseFilterCriteria,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
|
||||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "projectTeam",
|
|
||||||
minPermission: "read",
|
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getResponseDownloadFile(
|
|
||||||
parsedInput.surveyId,
|
|
||||||
parsedInput.format,
|
|
||||||
parsedInput.filterCriteria
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ZGetSurveyFilterDataAction = z.object({
|
|
||||||
surveyId: ZId,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getSurveyFilterDataAction = authenticatedActionClient
|
|
||||||
.inputSchema(ZGetSurveyFilterDataAction)
|
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
|
||||||
const survey = await getSurvey(parsedInput.surveyId);
|
|
||||||
|
|
||||||
if (!survey) {
|
|
||||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "projectTeam",
|
|
||||||
minPermission: "read",
|
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
|
||||||
if (!organizationBilling) {
|
|
||||||
throw new ResourceNotFoundError("Organization", organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
|
||||||
|
|
||||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
|
|
||||||
getTagsByEnvironmentId(survey.environmentId),
|
|
||||||
getResponseFilteringValues(parsedInput.surveyId),
|
|
||||||
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if survey follow-ups are enabled for the given organization.
|
|
||||||
*
|
|
||||||
* @param {string} organizationId The ID of the organization to check.
|
|
||||||
* @returns {Promise<void>} A promise that resolves if the permission is granted.
|
|
||||||
* @throws {ResourceNotFoundError} If the organization is not found.
|
|
||||||
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
|
|
||||||
*/
|
|
||||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
|
||||||
const organization = await getOrganization(organizationId);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
|
||||||
if (!isSurveyFollowUpsEnabled) {
|
|
||||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
|
||||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user?.id ?? "",
|
|
||||||
organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "projectTeam",
|
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.id),
|
|
||||||
minPermission: "readWrite",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { followUps } = parsedInput;
|
|
||||||
|
|
||||||
const oldSurvey = await getSurvey(parsedInput.id);
|
|
||||||
|
|
||||||
if (parsedInput.recaptcha?.enabled) {
|
|
||||||
await checkSpamProtectionPermission(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (followUps?.length) {
|
|
||||||
await checkSurveyFollowUpsPermission(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context for audit log
|
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldSurvey;
|
|
||||||
|
|
||||||
const newSurvey = await updateSurvey(parsedInput);
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
|
||||||
|
|
||||||
return newSurvey;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
-208
@@ -1,208 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { CheckCircle2, Sparkles } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
|
|
||||||
const FORMBRICKS_HOST = "https://app.formbricks.com";
|
|
||||||
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
|
|
||||||
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
|
|
||||||
|
|
||||||
interface WorkflowsPageProps {
|
|
||||||
userEmail: string;
|
|
||||||
organizationName: string;
|
|
||||||
billingPlan: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step = "prompt" | "followup" | "thankyou";
|
|
||||||
|
|
||||||
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [step, setStep] = useState<Step>("prompt");
|
|
||||||
const [promptValue, setPromptValue] = useState("");
|
|
||||||
const [detailsValue, setDetailsValue] = useState("");
|
|
||||||
const [responseId, setResponseId] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleGenerateWorkflow = async () => {
|
|
||||||
if (promptValue.trim().length < 100 || isSubmitting) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
surveyId: SURVEY_ID,
|
|
||||||
finished: false,
|
|
||||||
data: {
|
|
||||||
workflow: promptValue.trim(),
|
|
||||||
useremail: userEmail,
|
|
||||||
orgname: organizationName,
|
|
||||||
billingplan: billingPlan,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
setResponseId(json.data?.id ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStep("followup");
|
|
||||||
} catch {
|
|
||||||
setStep("followup");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitFeedback = async () => {
|
|
||||||
if (isSubmitting) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
if (responseId) {
|
|
||||||
try {
|
|
||||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
finished: true,
|
|
||||||
data: {
|
|
||||||
details: detailsValue.trim(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// silently fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(false);
|
|
||||||
setStep("thankyou");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipFeedback = async () => {
|
|
||||||
if (!responseId) {
|
|
||||||
setStep("thankyou");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
finished: true,
|
|
||||||
data: {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// silently fail
|
|
||||||
}
|
|
||||||
|
|
||||||
setStep("thankyou");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (step === "prompt") {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
|
||||||
<div className="w-full max-w-2xl space-y-8">
|
|
||||||
<div className="space-y-3 text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
|
|
||||||
<Sparkles className="h-6 w-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
|
|
||||||
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<textarea
|
|
||||||
value={promptValue}
|
|
||||||
onChange={(e) => setPromptValue(e.target.value)}
|
|
||||||
placeholder={t("workflows.placeholder")}
|
|
||||||
rows={5}
|
|
||||||
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
||||||
handleGenerateWorkflow();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="mt-3 flex items-center justify-between">
|
|
||||||
<span
|
|
||||||
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
|
|
||||||
{promptValue.trim().length} / 100
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
onClick={handleGenerateWorkflow}
|
|
||||||
disabled={promptValue.trim().length < 100 || isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
size="lg">
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
{t("workflows.generate_button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "followup") {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
|
||||||
<div className="w-full max-w-2xl space-y-8">
|
|
||||||
<div className="space-y-3 text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
|
||||||
<Sparkles className="h-6 w-6 text-brand-dark" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
|
|
||||||
{t("workflows.coming_soon_title")}
|
|
||||||
</h1>
|
|
||||||
<p className="mx-auto max-w-md text-base text-slate-500">
|
|
||||||
{t("workflows.coming_soon_description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
||||||
<label className="text-md mb-2 block font-medium text-slate-700">
|
|
||||||
{t("workflows.follow_up_label")}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={detailsValue}
|
|
||||||
onChange={(e) => setDetailsValue(e.target.value)}
|
|
||||||
placeholder={t("workflows.follow_up_placeholder")}
|
|
||||||
rows={4}
|
|
||||||
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
|
||||||
/>
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-3">
|
|
||||||
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
|
|
||||||
{t("common.skip")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmitFeedback}
|
|
||||||
disabled={!detailsValue.trim() || isSubmitting}
|
|
||||||
loading={isSubmitting}>
|
|
||||||
{t("workflows.submit_button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
|
||||||
<div className="w-full max-w-md space-y-6 text-center">
|
|
||||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
|
||||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
|
|
||||||
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Metadata } from "next";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { WorkflowsPage } from "./components/workflows-page";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Workflows",
|
|
||||||
};
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
if (!IS_FORMBRICKS_CLOUD) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
if (isBilling) {
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
|
||||||
if (!user) {
|
|
||||||
return redirect("/auth/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkflowsPage
|
|
||||||
userEmail={user.email}
|
|
||||||
organizationName={organization.name}
|
|
||||||
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
|
|
||||||
|
|
||||||
export default AppConnectionLoading;
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
|
|
||||||
|
|
||||||
export default AppConnectionPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
|
|
||||||
|
|
||||||
export default GeneralSettingsLoading;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
|
|
||||||
|
|
||||||
export default GeneralSettingsPage;
|
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
|
||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
|
||||||
import { getAirtableTables } from "@/lib/airtable/service";
|
|
||||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
|
||||||
|
|
||||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [surveys, integrations] = await Promise.all([
|
|
||||||
getSurveys(params.environmentId),
|
|
||||||
getIntegrations(params.environmentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
|
||||||
(integration): integration is TIntegrationAirtable => integration.type === "airtable"
|
|
||||||
);
|
|
||||||
|
|
||||||
let airtableArray: TIntegrationItem[] = [];
|
|
||||||
if (airtableIntegration?.config.key) {
|
|
||||||
airtableArray = await getAirtableTables(params.environmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
|
||||||
|
|
||||||
if (isReadOnly) {
|
|
||||||
return redirect("./");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
|
||||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
|
||||||
<div className="h-[75vh] w-full">
|
|
||||||
<AirtableWrapper
|
|
||||||
isEnabled={isEnabled}
|
|
||||||
airtableIntegration={airtableIntegration}
|
|
||||||
airtableArray={airtableArray}
|
|
||||||
environmentId={environment.id}
|
|
||||||
surveys={surveys}
|
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
locale={locale}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
|
||||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
|
||||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
|
|
||||||
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [surveys, slackIntegration] = await Promise.all([
|
|
||||||
getSurveys(params.environmentId),
|
|
||||||
getIntegrationByType(params.environmentId, "slack"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
|
||||||
|
|
||||||
if (isReadOnly) {
|
|
||||||
return redirect("./");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
|
||||||
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
|
||||||
<div className="h-[75vh] w-full">
|
|
||||||
<SlackWrapper
|
|
||||||
isEnabled={isEnabled}
|
|
||||||
environment={environment}
|
|
||||||
surveys={surveys}
|
|
||||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
locale={locale}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
|
|
||||||
|
|
||||||
export default LanguagesLoading;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
|
|
||||||
|
|
||||||
export default LanguagesPage;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
|
|
||||||
|
|
||||||
export { metadata };
|
|
||||||
export default ProjectSettingsLayout;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
|
|
||||||
|
|
||||||
export default ProjectLookSettingsLoading;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
|
|
||||||
|
|
||||||
export default ProjectLookSettingsPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
|
|
||||||
|
|
||||||
export default ProjectSettingsPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
|
|
||||||
|
|
||||||
export default TagsLoading;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { TagsPage } from "@/modules/projects/settings/tags/page";
|
|
||||||
|
|
||||||
export default TagsPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
|
|
||||||
|
|
||||||
export default ProjectTeams;
|
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
CHATWOOT_WEBSITE_TOKEN,
|
CHATWOOT_WEBSITE_TOKEN,
|
||||||
IS_CHATWOOT_CONFIGURED,
|
IS_CHATWOOT_CONFIGURED,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
|
SESSION_MAX_AGE,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||||
@@ -23,7 +25,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
{POSTHOG_KEY && user && (
|
{POSTHOG_KEY && user && (
|
||||||
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
||||||
@@ -39,7 +41,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
)}
|
)}
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</NextAuthProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||||
|
|
||||||
|
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||||
|
const { workspaceId } = await props.params;
|
||||||
|
return <ChartsListPage workspaceId={workspaceId} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartsPage;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user