Compare commits

..

9 Commits

Author SHA1 Message Date
Dhruwang 5239dfd9b1 chore(db): add nullable workspaceId to environment-owned models
Phase 1 of environment deprecation: adds optional workspaceId column
with FK, index, and cascade delete to all 9 environment-owned models
(Survey, Contact, ActionClass, ContactAttributeKey, Webhook, Tag,
Segment, Integration, ApiKeyEnvironment) and reverse relations on
Workspace.

Replaces the previous projectId approach (reverted in the prior commit)
to align with the Project → Workspace rename from epic/v5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:57:59 +05:30
Dhruwang d45cbefcff Merge remote-tracking branch 'origin/epic/v5' into revert/remove-projectid-from-env-models
# Conflicts:
#	apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx
#	apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx
#	apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/page.tsx
#	apps/web/app/(app)/environments/[environmentId]/actions.ts
#	apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
#	apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.tsx
#	apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.tsx
#	apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx
#	apps/web/modules/ee/contacts/[contactId]/components/activity-section.tsx
#	apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx
#	apps/web/modules/ee/contacts/layout.tsx
#	apps/web/modules/ee/whitelabel/remove-branding/actions.ts
#	apps/web/modules/environments/lib/utils.test.ts
#	apps/web/modules/environments/lib/utils.ts
#	apps/web/modules/projects/settings/general/components/delete-project.tsx
#	apps/web/modules/survey/editor/page.tsx
#	apps/web/modules/survey/list/page.tsx
#	apps/web/modules/survey/templates/page.tsx
#	apps/web/modules/workspaces/settings/actions.ts
#	apps/web/modules/workspaces/settings/look/page.tsx
2026-04-01 10:47:48 +05:30
Dhruwang f1c6180ae2 Revert "chore(db): add nullable projectId to environment-owned models (#7588)"
This reverts commit 71cca557fc.
2026-04-01 10:27:41 +05:30
Dhruwang Jariwala a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey 029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Dhruwang Jariwala 71cca557fc chore(db): add nullable projectId to environment-owned models (#7588) 2026-03-27 12:33:44 +05:30
Dhruwang Jariwala 1500b6f7f3 docs: deprecate environments migration plan (#7586)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 16:34:40 +04:00
Dhruwang 2c9fbf83e4 chore: merge epic/v5 into chore/deprecate-environments 2026-03-26 15:10:31 +05:30
Matti Nannt 81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
1017 changed files with 19277 additions and 43310 deletions
-9
View File
@@ -1,9 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''
+9 -39
View File
@@ -38,6 +38,15 @@ LOG_LEVEL=info
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/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -94,12 +103,6 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
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_AUTH_DISABLED=1
@@ -138,31 +141,6 @@ AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET=
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, gcp, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=gcp
# AI_MODEL=gemini-2.5-flash
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_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
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
@@ -216,14 +194,6 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# 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)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@v3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v3
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+48 -37
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
@@ -85,48 +85,65 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Start RustFS Server
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: |
set -euo pipefail
# Start RustFS server in background
# Start MinIO server in background
docker run -d \
--name rustfs-server \
--name minio-server \
-p 9000:9000 \
-p 9001:9001 \
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "RustFS server started"
echo "MinIO server started"
- name: Bootstrap RustFS bucket and browser upload CORS
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
run: |
@@ -225,14 +242,8 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
run: cat app.log
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -3
View File
@@ -2,7 +2,6 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -40,7 +39,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -50,7 +49,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv
# Playwright
**/test-results/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+1 -13
View File
@@ -1,13 +1 @@
#!/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
pnpm lint-staged
+25 -1
View File
@@ -127,10 +127,34 @@ 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.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker
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.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development
### Prerequisites
@@ -223,4 +247,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.
<a id="readme-de"></a>
<p align="right"><a href="#top">🔼 Back to top</a></p>
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/addon-links": "10.3.5",
"@storybook/addon-onboarding": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
@@ -14,7 +14,7 @@ interface ConnectWithFormbricksProps {
environment: TEnvironment;
publicDomain: string;
appSetupCompleted: boolean;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
}
export const ConnectWithFormbricks = ({
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
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 { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -19,7 +19,7 @@ const tabs = [
interface OnboardingSetupInstructionsProps {
environmentId: string;
publicDomain: string;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean;
}
@@ -4,7 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -24,12 +24,12 @@ const Page = async (props: ConnectPageProps) => {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;
const channel = workspace.config.channel || null;
const publicDomain = getPublicDomain();
@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
@@ -16,12 +16,12 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps {
project: TProject;
workspace: TWorkspace;
user: TUser;
environmentId: string;
}
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -48,7 +48,7 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project);
const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate);
};
@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
const mockWorkspace: TWorkspace = {
id: "workspace1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
name: "Test Workspace",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
@@ -32,7 +32,7 @@ const mockProject: TProject = {
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
name: "$[workspaceName] Survey",
blocks: [
{
id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
headline: { default: "$[workspaceName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Workspace Survey");
});
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});
@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
// replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...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 };
};
@@ -4,9 +4,9 @@ import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
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 { getUserWorkspaces, getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
@@ -37,18 +37,18 @@ const Page = async (props: XMTemplatePageProps) => {
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
const workspace = await getWorkspaceByEnvironmentId(environment.id);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);
const workspaces = await getUserWorkspaces(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 && (
<XMTemplateList workspace={workspace} user={user} environmentId={environment.id} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name,
}));
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -2,7 +2,7 @@ import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/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";
const LandingLayout = async (props: {
@@ -24,11 +24,11 @@ const LandingLayout = async (props: {
return notFound();
}
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) {
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
if (workspaces.length !== 0) {
const firstWorkspace = workspaces[0];
const environments = await getEnvironments(firstWorkspace.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation";
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)/environments/[environmentId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -26,8 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
const { isMember } = getAccessFlags(membership?.role);
return (
<div className="flex min-h-full min-w-full flex-row">
@@ -35,19 +34,17 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<WorkspaceAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]}
/>
</div>
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: {
const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
);
};
export default ProjectOnboardingLayout;
export default WorkspaceOnboardingLayout;
@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -4,10 +4,10 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
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: {
params: Promise<{ organizationId: string }>;
@@ -32,12 +32,12 @@ const OnboardingLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id),
getOrganizationWorkspacesCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`);
}
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,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 (
<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")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
TProjectMode,
TProjectUpdateInput,
ZProjectUpdateInput,
} from "@formbricks/types/project";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
TWorkspaceUpdateInput,
ZWorkspaceUpdateInput,
} from "@formbricks/types/workspace";
import { createWorkspaceAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
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 { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps {
interface WorkspaceSettingsProps {
organizationId: string;
projectMode: TProjectMode;
channel: TProjectConfigChannel;
industry: TProjectConfigIndustry;
workspaceMode: TWorkspaceMode;
channel: TWorkspaceConfigChannel;
industry: TWorkspaceConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
userWorkspacesCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
export const WorkspaceSettings = ({
organizationId,
projectMode,
workspaceMode,
channel,
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
userWorkspacesCount,
publicDomain,
}: ProjectSettingsProps) => {
}: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
@@ -71,7 +71,7 @@ export const ProjectSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
const createWorkspaceResponse = await createWorkspaceAction({
organizationId,
data: {
...data,
@@ -81,14 +81,14 @@ export const ProjectSettings = ({
},
});
if (createProjectResponse?.data) {
if (createWorkspaceResponse?.data) {
// get production environment
const productionEnvironment = createProjectResponse.data.environments.find(
(environment) => environment.type === "production"
const productionEnvironment = createWorkspaceResponse.data.environments.find(
(environment: { type: string }) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
// Remove filters when creating a new workspace
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
}
@@ -96,11 +96,11 @@ export const ProjectSettings = ({
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (projectMode === "cx") {
} else if (workspaceMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProjectResponse);
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage);
}
} catch (error) {
@@ -109,15 +109,15 @@ export const ProjectSettings = ({
}
};
const form = useForm<TProjectUpdateInput>({
const form = useForm<TWorkspaceUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
resolver: zodResolver(ZWorkspaceUpdateInput),
});
const projectName = form.watch("name");
const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -132,7 +132,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="flex w-1/2 flex-col space-y-4">
<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
control={form.control}
name="styling.brandColor.light"
@@ -184,7 +184,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField
control={form.control}
name="teamIds"
@@ -242,7 +242,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -2,30 +2,34 @@ import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
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 { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ProjectSettingsPageProps {
interface WorkspaceSettingsPageProps {
params: Promise<{
organizationId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
mode?: TProjectMode;
channel?: TWorkspaceConfigChannel;
industry?: TWorkspaceConfigIndustry;
mode?: TWorkspaceMode;
}>;
}
const Page = async (props: ProjectSettingsPageProps) => {
const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -39,7 +43,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
@@ -57,18 +61,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
<WorkspaceSettings
organizationId={params.organizationId}
projectMode={mode}
workspaceMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
userWorkspacesCount={workspaces.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -7,29 +7,29 @@ import {
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getOrganizationWorkspacesLimit,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateProjectAction = z.object({
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZProjectUpdateInput,
data: ZWorkspaceUpdateInput,
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
@@ -40,7 +40,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
schema: ZWorkspaceUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -53,10 +53,10 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
throw new ResourceNotFoundError("Organization", organizationId);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
@@ -68,7 +68,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
@@ -81,9 +81,9 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
ctx.auditLoggingCtx.workspaceId = workspace.id;
ctx.auditLoggingCtx.newObject = workspace;
return workspace;
})
);
@@ -112,16 +112,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
return await getOrganizationsByUserId(ctx.user.id);
});
const ZGetProjectsForSwitcherAction = z.object({
const ZGetWorkspacesForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
});
/**
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the project switcher.
* Called on-demand when user opens the workspace switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction)
export const getWorkspacesForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetWorkspacesForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -134,11 +134,11 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
],
});
// Need membership for getProjectsByUserId (1 DB query)
// Need membership for getWorkspacesByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
return await getWorkspacesByUserId(ctx.user.id, membership);
});
@@ -5,7 +5,7 @@ import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
@@ -25,10 +25,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
workspace, // Current workspace details
environments, // All workspace environments (for environment switcher)
isAccessControlAllowed,
projectPermission,
workspacePermission,
license,
responseCount,
} = layoutData;
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new ResourceNotFoundError(t("common.workspace"), null);
// Validate that workspace permission exists for members
if (isMember && !workspacePermission) {
throw new Error(t("common.workspace_permission_not_found"));
}
return (
@@ -70,23 +70,19 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment={environment}
organization={organization}
user={user}
project={{ id: project.id, name: project.name }}
workspace={{ id: workspace.id, name: workspace.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
currentProjectId={project.id}
currentWorkspaceId={workspace.id}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}
@@ -2,108 +2,64 @@
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Cog,
FoldersIcon,
Loader2,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
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 {
getOrganizationsForSwitcherAction,
getProjectsForSwitcherAction,
} from "@/app/(app)/environments/[environmentId]/actions";
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 { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
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,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
import packageJson from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
project: { id: string; name: string };
workspace: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
if (pathname.includes("/settings/")) {
return false;
}
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
if (accountSettingsPattern.test(pathname)) {
return false;
}
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const MainNavigation = ({
environment,
organization,
user,
project,
workspace,
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
isMultiOrgEnabled,
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -113,12 +69,7 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const [isPending, startTransition] = useTransition();
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const disabledNavigationMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
@@ -141,7 +92,7 @@ export const MainNavigation = ({
}, [isCollapsed]);
useEffect(() => {
// Auto collapse project navbar on org and account settings
// Auto collapse workspace navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
@@ -155,7 +106,6 @@ export const MainNavigation = ({
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/environments/${environment.id}/contacts`,
@@ -165,17 +115,22 @@ export const MainNavigation = ({
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
{
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"),
disabled: isMembershipPending || isBilling,
},
],
[t, environment.id, pathname, isMembershipPending, isBilling]
[t, environment.id, pathname, isFormbricksCloud]
);
const dropdownNavigation = [
@@ -198,183 +153,6 @@ export const MainNavigation = ({
},
];
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${environment.id}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${environment.id}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${environment.id}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${environment.id}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${environment.id}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${environment.id}/workspace/tags`,
},
];
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${environment.id}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environment.id}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environment.id}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environment.id}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environment.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
setWorkspaceLoadError(null);
try {
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setWorkspaceLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
);
} finally {
setIsLoadingProjects(false);
setHasInitializedProjects(true);
}
}, [organization.id, t]);
useEffect(() => {
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
return;
}
loadProjects();
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
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 (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setOrganizationLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
);
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
!isOrganizationDropdownOpen ||
organizations.length > 0 ||
isLoadingOrganizations ||
organizationLoadError
) {
return;
}
loadOrganizations();
}, [
isOrganizationDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
useEffect(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
@@ -404,89 +182,11 @@ export const MainNavigation = ({
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = isBilling
? getBillingFallbackPath(environment.id, isFormbricksCloud)
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleProjectCreate = () => {
if (!hasInitializedProjects || isLoadingProjects) {
return;
}
if (projects.length >= organizationProjectsLimit) {
setOpenProjectLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${environment.id}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${environment.id}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
};
const switcherTriggerClasses = cn(
"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",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return (
<>
{project && (
{workspace && (
<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",
@@ -522,24 +222,24 @@ export const MainNavigation = ({
</div>
{/* Main Nav Switch */}
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
{!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>
@@ -563,210 +263,38 @@ export const MainNavigation = ({
</Link>
)}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</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">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingProjects || isInitialProjectsLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects &&
!isInitialProjectsLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setProjects([]);
},
t("common.try_again")
)}
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === project.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleProjectCreate}
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>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Cog 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={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<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 &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!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>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
if (setting.hidden) return null;
return (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* User Switch */}
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button
type="button"
aria-label={isCollapsed ? t("common.account_settings") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
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="grow overflow-hidden">
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p
title={user?.email}
className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
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-500">{t("common.account")}</p>
<p className="text-sm text-slate-700">{t("common.account")}</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</>
)}
</button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -775,6 +303,8 @@ export const MainNavigation = ({
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link
href={link.href}
@@ -788,6 +318,7 @@ export const MainNavigation = ({
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
@@ -810,28 +341,6 @@ export const MainNavigation = ({
</div>
</aside>
)}
{openProjectLimitModal && (
<ProjectLimitModal
open={openProjectLimitModal}
setOpen={setOpenProjectLimitModal}
buttons={projectLimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</>
);
};
@@ -1,7 +1,6 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps {
@@ -11,8 +10,6 @@ interface NavigationLinkProps {
children: React.ReactNode;
linkText: string;
isTextVisible: boolean;
disabled?: boolean;
disabledMessage?: string;
}
export const NavigationLink = ({
@@ -22,34 +19,10 @@ export const NavigationLink = ({
children,
linkText,
isTextVisible = true,
disabled = false,
disabledMessage,
}: NavigationLinkProps) => {
const tooltipText = disabled ? disabledMessage || linkText : linkText;
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";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const getColorClass = (baseClass: string) => {
if (disabled) {
return disabledClass;
}
return cn(baseClass, isActive ? activeClass : inactiveClass);
};
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
const label = (
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
);
return (
<>
@@ -57,37 +30,35 @@ export const NavigationLink = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
{disabled ? (
<div className="flex items-center">{children}</div>
) : (
<Link href={href}>{children}</Link>
)}
<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">{tooltipText}</TooltipContent>
<TooltipContent side="right">{linkText}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
{disabled ? (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center">
{children}
{label}
</div>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{disabledMessage || linkText}
</PopoverContent>
</Popover>
) : (
<Link href={href} className="flex items-center">
{children}
{label}
</Link>
)}
<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>
)}
</>
@@ -2,16 +2,16 @@
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
currentProjectId: string;
currentWorkspaceId: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
@@ -22,36 +22,33 @@ interface TopControlBarProps {
export const TopControlBar = ({
environments,
currentOrganizationId,
currentProjectId,
currentWorkspaceId,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
membershipRole,
}: TopControlBarProps) => {
const { isMember, isBilling } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const { isMember } = getAccessFlags(membershipRole);
const { environment } = useEnvironment();
return (
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<ProjectAndOrgSwitch
<WorkspaceAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
currentProjectId={currentProjectId}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
@@ -1,13 +1,13 @@
import Link from "next/link";
import { ReactNode } from "react";
interface ProjectNavItemProps {
interface WorkspaceNavItemProps {
href: string;
children: ReactNode;
isActive: boolean;
}
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
const activeClass = "bg-slate-50 font-semibold";
const inactiveClass = "hover:bg-slate-50";
@@ -25,7 +25,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps {
@@ -36,7 +35,6 @@ interface OrganizationBreadcrumbProps {
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
isMembershipPending: boolean;
}
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
@@ -58,7 +56,6 @@ export const OrganizationBreadcrumb = ({
isFormbricksCloud,
isMember,
isOwnerOrManager,
isMembershipPending,
}: OrganizationBreadcrumbProps) => {
const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
@@ -114,12 +111,8 @@ export const OrganizationBreadcrumb = ({
}
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentEnvironmentId) {
router.push(`/environments/${currentEnvironmentId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -145,14 +138,17 @@ export const OrganizationBreadcrumb = ({
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
hidden: !isOwnerOrManager,
},
{
id: "domain",
@@ -170,11 +166,7 @@ export const OrganizationBreadcrumb = ({
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
hidden: isFormbricksCloud || isMember,
},
];
@@ -256,30 +248,14 @@ export const OrganizationBreadcrumb = ({
{organizationSettings.map((setting) => {
return setting.hidden ? null : (
<div key={setting.id}>
{setting.disabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{setting.disabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
hidden={setting.hidden}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</div>
@@ -2,44 +2,40 @@
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 { WorkspaceBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/workspace-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
interface WorkspaceAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentWorkspaceId?: string;
currentWorkspaceName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
export const WorkspaceAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentWorkspaceId,
currentWorkspaceName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => {
}: WorkspaceAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -54,22 +50,19 @@ export const ProjectAndOrgSwitch = ({
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
{currentWorkspaceId && currentEnvironmentId && (
<WorkspaceBreadcrumb
currentWorkspaceId={currentWorkspaceId}
currentWorkspaceName={currentWorkspaceName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
)}
{showEnvironmentBreadcrumb && (
@@ -1,15 +1,13 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
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 { getWorkspacesForSwitcherAction } 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,
@@ -19,26 +17,25 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { useWorkspace } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
interface WorkspaceBreadcrumbProps {
currentWorkspaceId: string;
currentWorkspaceName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
@@ -48,61 +45,59 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
return pattern.test(pathname);
};
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
export const WorkspaceBreadcrumb = ({
currentWorkspaceId,
currentWorkspaceName,
isOwnerOrManager,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: ProjectBreadcrumbProps) => {
}: WorkspaceBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
const [workspaces, setWorkspaces] = 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
// Get current workspace 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 || "";
const { workspace: currentWorkspace } = useWorkspace();
const workspaceName = currentWorkspace?.name || currentWorkspaceName || "";
// Lazy-load projects when dropdown opens
// Lazy-load workspaces 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);
if (isWorkspaceDropdownOpen && workspaces.length === 0 && !isLoadingWorkspaces && !loadError) {
setIsLoadingWorkspaces(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
getWorkspacesForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
// Sort workspaces by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
setWorkspaces(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
logger.error(error, "Failed to load workspaces");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
setIsLoadingWorkspaces(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
const projectSettings = [
const workspaceSettings = [
{
id: "general",
label: t("common.general"),
@@ -139,38 +134,30 @@ export const ProjectBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
];
const areProjectSettingsDisabled = isMembershipPending || isBilling;
const projectSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
if (!currentWorkspace) {
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === currentWorkspaceId) return;
startTransition(() => {
setIsProjectDropdownOpen(false);
router.push(targetPath);
router.push(`/workspaces/${workspaceId}/`);
});
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
const handleAddWorkspace = () => {
if (workspaces.length >= organizationWorkspacesLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
setOpenCreateWorkspaceModal(true);
};
const handleProjectSettingsNavigation = (settingId: string) => {
const handleWorkspaceSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
@@ -204,17 +191,17 @@ export const ProjectBreadcrumb = ({
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}>
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
id="workspaceDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{workspaceName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -224,35 +211,35 @@ export const ProjectBreadcrumb = ({
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
{isLoadingWorkspaces && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
{!isLoadingWorkspaces && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
setWorkspaces([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
{!isLoadingWorkspaces && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
checked={proj.id === currentWorkspaceId}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
@@ -260,26 +247,9 @@ export const ProjectBreadcrumb = ({
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMembershipPending || !isOwnerOrManager ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action")}
</PopoverContent>
</Popover>
) : (
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
onClick={handleAddWorkspace}
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} />
@@ -293,48 +263,31 @@ export const ProjectBreadcrumb = ({
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<div key={setting.id}>
{areProjectSettingsDisabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{projectSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
{workspaceSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveWorkspaceSetting(pathname, setting.id)}
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
<WorkspaceLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
workspaceLimit={organizationWorkspacesLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
{openCreateWorkspaceModal && (
<CreateWorkspaceModal
open={openCreateWorkspaceModal}
setOpen={setOpenCreateWorkspaceModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>
@@ -3,11 +3,11 @@
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TWorkspace } from "@formbricks/types/workspace";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
workspace: TWorkspace;
organization: TOrganization;
organizationId: string;
}
@@ -22,12 +22,12 @@ export const useEnvironment = () => {
return context;
};
export const useProject = () => {
export const useWorkspace = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
return { workspace: null };
}
return { project: context.project };
return { workspace: context.workspace };
};
export const useOrganization = () => {
@@ -41,25 +41,25 @@ export const useOrganization = () => {
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
workspace: TWorkspace;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
workspace,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
workspace,
organization,
organizationId: project.organizationId,
organizationId: workspace.organizationId,
}),
[environment, project, organization]
[environment, workspace, organization]
);
return (
@@ -27,7 +27,7 @@ const EnvLayout = async (props: {
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
workspace={layoutData.workspace}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { getProjectsByUserId } from "./project";
import { getWorkspacesByUserId } from "./workspace";
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
workspace: {
findMany: vi.fn(),
},
},
}));
describe("Project", () => {
describe("getUserProjects", () => {
describe("Workspace", () => {
describe("getUserWorkspaces", () => {
const mockAdminMembership: TMembership = {
role: "manager",
organizationId: "org1",
@@ -29,17 +29,17 @@ describe("Project", () => {
accepted: true,
};
test("should return projects for admin role", async () => {
const mockProjects = [
{ id: "project1", name: "Project 1" },
{ id: "project2", name: "Project 2" },
test("should return workspaces for admin role", async () => {
const mockWorkspaces = [
{ id: "workspace1", name: "Workspace 1" },
{ id: "workspace2", name: "Workspace 2" },
];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockAdminMembership);
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
@@ -48,20 +48,20 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
test("should return projects for member role with team restrictions", async () => {
const mockProjects = [{ id: "project1", name: "Project 1" }];
test("should return workspaces for member role with team restrictions", async () => {
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockMemberMembership);
const result = await getWorkspacesByUserId("user1", mockMemberMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
projectTeams: {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -78,13 +78,13 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
test("should return empty array when no projects found", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
test("should return empty array when no workspaces found", async () => {
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]);
const result = await getProjectsByUserId("user1", mockAdminMembership);
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
expect(result).toEqual([]);
});
@@ -95,27 +95,27 @@ describe("Project", () => {
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(
new DatabaseError("Database error")
);
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
});
test("should validate inputs correctly", async () => {
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
});
test("should validate membership input correctly", async () => {
const invalidMembership = {} as TMembership;
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow();
});
test("should handle owner role like manager", async () => {
@@ -126,12 +126,12 @@ describe("Project", () => {
accepted: true,
};
const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
const result = await getProjectsByUserId("user1", mockOwnerMembership);
const result = await getWorkspacesByUserId("user1", mockOwnerMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
@@ -140,7 +140,7 @@ describe("Project", () => {
name: true,
},
});
expect(result).toEqual(mockProjects);
expect(result).toEqual(mockWorkspaces);
});
});
});
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { validateInputs } from "@/lib/utils/validate";
export const getProjectsByUserId = reactCache(
export const getWorkspacesByUserId = reactCache(
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
validateInputs([userId, ZString], [orgMembership, ZMembership]);
let projectWhereClause: Prisma.ProjectWhereInput = {};
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {};
if (orgMembership.role === "member") {
projectWhereClause = {
projectTeams: {
workspaceWhereClause = {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -29,17 +29,17 @@ export const getProjectsByUserId = reactCache(
}
try {
const projects = await prisma.project.findMany({
const workspaces = await prisma.workspace.findMany({
where: {
organizationId: orgMembership.organizationId,
...projectWhereClause,
...workspaceWhereClause,
},
select: {
id: true,
name: true,
},
});
return projects;
return workspaces;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -1,6 +1,5 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -13,7 +12,11 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
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`);
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -14,9 +14,9 @@ const AccountSettingsLayout = async (props: {
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
const [organization, workspace, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
@@ -24,8 +24,8 @@ const AccountSettingsLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
@@ -40,7 +40,7 @@ export const EditAlerts = ({
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
</p>
<NotificationSwitch
surveyOrProjectOrOrganizationId={membership.organization.id}
surveyOrWorkspaceOrOrganizationId={membership.organization.id}
notificationSettings={user.notificationSettings!}
notificationType={"unsubscribedOrganizationIds"}
autoDisableNotificationType={autoDisableNotificationType}
@@ -66,13 +66,13 @@ export const EditAlerts = ({
</TooltipProvider>
</div>
{membership.organization.projects.some((project) =>
project.environments.some((environment) => environment.surveys.length > 0)
{membership.organization.workspaces.some((workspace) =>
workspace.environments.some((environment) => environment.surveys.length > 0)
) ? (
<div className="grid-cols-8 space-y-1 p-2">
{membership.organization.projects.map((project) => (
<div key={project.id}>
{project.environments.map((environment) => (
{membership.organization.workspaces.map((workspace) => (
<div key={workspace.id}>
{workspace.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
@@ -80,11 +80,11 @@ export const EditAlerts = ({
key={survey.name}>
<div className="col-span-2 text-left">
<div className="font-medium text-slate-900">{survey.name}</div>
<div className="text-xs text-slate-400">{project.name}</div>
<div className="text-xs text-slate-400">{workspace.name}</div>
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrProjectOrOrganizationId={survey.id}
surveyOrWorkspaceOrOrganizationId={survey.id}
notificationSettings={user.notificationSettings!}
notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType}
@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string;
surveyOrWorkspaceOrOrganizationId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string;
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
}
export const NotificationSwitch = ({
surveyOrProjectOrOrganizationId,
surveyOrWorkspaceOrOrganizationId,
notificationSettings,
notificationType,
autoDisableNotificationType,
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
const router = useRouter();
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -38,21 +38,21 @@ export const NotificationSwitch = ({
let updatedNotificationSettings = { ...notificationSettings };
if (notificationType === "unsubscribedOrganizationIds") {
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) {
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
(id) => id !== surveyOrProjectOrOrganizationId
(id) => id !== surveyOrWorkspaceOrOrganizationId
);
} else {
updatedNotificationSettings.unsubscribedOrganizationIds = [
...unsubscribedOrganizationIds,
surveyOrProjectOrOrganizationId,
surveyOrWorkspaceOrOrganizationId,
];
}
} else {
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
[surveyOrWorkspaceOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId],
};
}
@@ -76,12 +76,12 @@ export const NotificationSwitch = ({
useEffect(() => {
if (
autoDisableNotificationType &&
autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId &&
isChecked
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(
@@ -95,7 +95,9 @@ export const NotificationSwitch = ({
break;
case "unsubscribedOrganizationIds":
if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
if (
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
) {
handleSwitchChange();
toast.success(
t(
@@ -22,9 +22,9 @@ const setCompleteNotificationSettings = (
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
for (const project of membership.organization.projects) {
for (const workspace of membership.organization.workspaces) {
// set default values for alerts
for (const environment of project.environments) {
for (const environment of workspace.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
@@ -47,17 +47,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
},
OR: [
{
// Fetch all projects if user role is owner or manager
// Fetch all workspaces if user role is owner or manager
role: {
in: ["owner", "manager"],
},
},
{
// Filter projects based on team membership if user is not owner or manager
// Filter workspaces based on team membership if user is not owner or manager
organization: {
projects: {
workspaces: {
some: {
projectTeams: {
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -79,12 +79,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
select: {
id: true,
name: true,
projects: {
workspaces: {
// Apply conditional filtering based on user's role
where: {
OR: [
{
// Fetch all projects if user is owner or manager
// Fetch all workspaces if user is owner or manager
organization: {
memberships: {
some: {
@@ -97,8 +97,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
},
},
{
// Only include projects accessible through teams if user is not owner or manager
projectTeams: {
// Only include workspaces accessible through teams if user is not owner or manager
workspaceTeams: {
some: {
team: {
teamUsers: {
@@ -4,7 +4,7 @@ export interface Membership {
organization: {
id: string;
name: string;
projects: {
workspaces: {
id: string;
name: string;
environments: {
@@ -6,18 +6,19 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return {
@@ -84,15 +85,11 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await requestPasswordReset(ctx.user, "profile");
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
@@ -116,14 +116,10 @@ export const EditProfileDetailsForm = ({
setShowModal(true);
} else {
try {
const result = await updateUserAction({
await updateUserAction({
...data,
name: data.name.trim(),
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload();
form.reset(data);
@@ -1,9 +1,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyUserPassword } from "@/lib/user/password";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique } from "./user";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
@@ -1,5 +1,42 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
@@ -22,9 +22,8 @@ export const OrganizationSettingsNavbar = ({
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const isMembershipPending = membershipRole === undefined || loading;
const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember;
const { t } = useTranslation();
const navigation = [
@@ -41,15 +40,19 @@ export const OrganizationSettingsNavbar = ({
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${environmentId}/settings/feedback-record-directories`,
current: pathname?.includes("/feedback-record-directories"),
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
hidden: !isOwner,
},
{
id: "domain",
@@ -62,18 +65,14 @@ export const OrganizationSettingsNavbar = ({
id: "billing",
label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud,
hidden: !isFormbricksCloud || loading,
current: pathname?.includes("/billing"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"),
},
];
@@ -14,7 +14,7 @@ interface SurveyWithSlug {
environment: {
id: string;
type: "production" | "development";
project: {
workspace: {
id: string;
name: string;
};
@@ -40,7 +40,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
},
{
label: t("environments.settings.domain.workspace"),
key: "project",
key: "workspace",
},
{
label: t("environments.settings.domain.pretty_url"),
@@ -81,7 +81,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
{survey.name}
</Link>
</TableCell>
<TableCell>{survey.environment.project.name}</TableCell>
<TableCell>{survey.environment.workspace.name}</TableCell>
<TableCell>
<IdBadge id={survey.slug ?? ""} />
</TableCell>
@@ -25,8 +25,8 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
key: "workspaces",
labelKey: t("environments.settings.enterprise.license_feature_workspaces"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
@@ -0,0 +1 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
@@ -1,218 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { updateOrganizationAISettingsAction } from "./actions";
import { ZOrganizationAISettingsInput } from "./schemas";
const mocks = vi.hoisted(() => ({
isInstanceAIConfigured: vi.fn(),
checkAuthorizationUpdated: vi.fn(),
deleteOrganization: vi.fn(),
getOrganization: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getTranslate: vi.fn(),
updateOrganization: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/lib/organization/service", () => ({
deleteOrganization: mocks.deleteOrganization,
getOrganization: mocks.getOrganization,
updateOrganization: mocks.updateOrganization,
}));
vi.mock("@/lib/ai/service", () => ({
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: mocks.getTranslate,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
const organizationId = "cm9gptbhg0000192zceq9ayuc";
describe("organization AI settings actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key
);
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
test("accepts AI toggle updates", () => {
expect(
ZOrganizationAISettingsInput.parse({
isAISmartToolsEnabled: true,
})
).toEqual({
isAISmartToolsEnabled: true,
});
});
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
const ctx = {
user: { id: "user_1", locale: "en-US" },
auditLoggingCtx: {},
};
const parsedInput = {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
};
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId,
access: [
{
type: "organization",
schema: ZOrganizationAISettingsInput,
data: parsedInput.data,
roles: ["owner", "manager"],
},
],
});
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
expect(ctx.auditLoggingCtx).toMatchObject({
organizationId,
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
test("propagates authorization failures so members cannot update AI settings", async () => {
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_member", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(AuthorizationError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("rejects enabling AI when the instance AI provider is not configured", async () => {
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(OperationNotAllowedError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("allows enabling AI when the instance configuration is valid", async () => {
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: true,
});
});
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: false,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: false,
});
});
});
@@ -2,44 +2,13 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { isInstanceAIConfigured } from "@/lib/ai/service";
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 { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getTranslate } from "@/lingodotdev/server";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId,
@@ -49,114 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
})
)
);
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
const resolveOrganizationAISettings = ({
data,
organization,
}: {
data: z.infer<typeof ZOrganizationAISettingsInput>;
organization: TOrganizationAISettings;
}): TResolvedOrganizationAISettings => {
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
};
};
const assertOrganizationAISettingsUpdateAllowed = ({
isInstanceAIConfigured,
resolvedSettings,
t,
}: {
isInstanceAIConfigured: boolean;
resolvedSettings: TResolvedOrganizationAISettings;
t: Awaited<ReturnType<typeof getTranslate>>;
}) => {
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
}
};
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) => {
const t = await getTranslate(ctx.user.locale);
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
}
const resolvedSettings = resolveOrganizationAISettings({
data: parsedInput.data,
organization,
});
assertOrganizationAISettingsUpdateAllowed({
isInstanceAIConfigured: isInstanceAIConfigured(),
resolvedSettings,
t,
});
return updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationAISettingsInput,
data: parsedInput.data,
roles: ["owner", "manager"],
});
}
)
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({
@@ -168,10 +49,7 @@ export const deleteOrganizationAction = authenticatedActionClient
.action(
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const t = await getTranslate(ctx.user.locale);
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
}
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -1,118 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
}: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const aiEnablementState = getOrganizationAIEnablementState({
isInstanceConfigured: isInstanceAIConfigured,
});
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
toast.error(getFormattedErrorMessage(response));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
} finally {
setLoadingField(null);
}
};
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
<Alert variant="warning">
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
</Alert>
)}
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -1,5 +1,4 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -12,7 +11,6 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
@@ -62,15 +60,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role}
/>
</SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
/>
</SettingsCard>
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -1,13 +0,0 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationAISettingsInput,
});
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -11,9 +11,9 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
const [organization, workspace, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
@@ -21,8 +21,8 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
if (!workspace) {
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
@@ -8,7 +8,7 @@ import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/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 { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { getSurveySummary } from "./summary/lib/surveySummary";
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
@@ -36,9 +36,9 @@ export const getResponsesAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -70,9 +70,9 @@ export const getSurveySummaryAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -98,9 +98,9 @@ export const getResponseCountAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -126,9 +126,9 @@ export const getDisplaysWithContactAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -3,22 +3,25 @@
import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { TSurvey } from "@formbricks/types/surveys/types";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
activeId: string;
}
export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
activeId,
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { environment } = useEnvironment();
const { survey } = useSurvey();
const url = `/environments/${environment.id}/surveys/${survey.id}`;
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const navigation = [
{
@@ -28,7 +31,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
revalidateSurveyIdPath(environment.id, survey.id);
revalidateSurveyIdPath(environmentId, survey.id);
},
},
{
@@ -38,7 +41,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
revalidateSurveyIdPath(environment.id, survey.id);
revalidateSurveyIdPath(environmentId, survey.id);
},
},
];
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import React, { createContext, useCallback, useContext, useState } from "react";
import {
ElementOption,
ElementOptions,
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
export interface DateRange {
from: Date | undefined;
to?: Date;
to?: Date | undefined;
}
interface FilterDateContextProps {
@@ -41,8 +41,6 @@ interface FilterDateContextProps {
dateRange: DateRange;
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
resetState: () => void;
refreshAnalysisData: () => Promise<void>;
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
}
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
@@ -63,7 +61,6 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
from: undefined,
to: getTodayDate(),
});
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
const resetState = useCallback(() => {
setDateRange({
@@ -76,43 +73,20 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
}, []);
const refreshAnalysisData = useCallback(async () => {
await refreshHandlerRef.current?.();
}, []);
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
refreshHandlerRef.current = handler;
return () => {
if (refreshHandlerRef.current === handler) {
refreshHandlerRef.current = null;
}
};
}, []);
const contextValue = useMemo(
() => ({
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
refreshAnalysisData,
registerAnalysisRefreshHandler,
}),
[
dateRange,
refreshAnalysisData,
registerAnalysisRefreshHandler,
resetState,
selectedFilter,
selectedOptions,
]
return (
<ResponseFilterContext.Provider
value={{
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
}}>
{children}
</ResponseFilterContext.Provider>
);
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
};
const useResponseFilter = () => {
@@ -1,22 +0,0 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="" />
<div className="flex h-9 animate-pulse gap-2">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="summary" />
</PageContentWrapper>
);
};
export default Loading;
@@ -2,8 +2,6 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
@@ -15,7 +13,6 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
interface ResponsePageProps {
@@ -49,8 +46,8 @@ export const ResponsePage = ({
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { t } = useTranslation();
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
@@ -89,34 +86,6 @@ export const ResponsePage = ({
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
const refetchResponses = useCallback(async () => {
setIsFetchingFirstPage(true);
try {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
if (getResponsesActionResponse?.serverError) {
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
}
const freshResponses = getResponsesActionResponse?.data ?? [];
setResponses(freshResponses);
setPage(1);
setHasMore(freshResponses.length >= responsesPerPage);
} finally {
setIsFetchingFirstPage(false);
}
}, [filters, responsesPerPage, surveyId]);
useEffect(() => {
return registerAnalysisRefreshHandler(refetchResponses);
}, [refetchResponses, registerAnalysisRefreshHandler]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
}, [survey]);
@@ -165,8 +134,6 @@ export const ResponsePage = ({
}
};
fetchFilteredResponses();
// page is intentionally omitted to avoid refetching after the initial page setup.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return (
@@ -29,7 +29,6 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -202,13 +201,7 @@ export const ResponseTable = ({
};
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
const result = await deleteResponseAction({
responseId,
decrementQuotas: params?.decrementQuotas ?? false,
});
if (result?.serverError) {
throw new Error(getFormattedErrorMessage(result));
}
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
};
// Handle downloading selected responses
@@ -1,4 +1,5 @@
import { TFunction } from "i18next";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -8,7 +9,6 @@ import {
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
import { capitalize } from "@/lib/utils/object";
export const getAddressFieldLabel = (field: string, t: TFunction) => {
switch (field) {
@@ -1,23 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.responses")} />
<div className="flex h-9 animate-pulse gap-1.5">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="responseTable" />
</PageContentWrapper>
);
};
export default Loading;
@@ -64,6 +64,8 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -74,7 +76,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation activeId="responses" />
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -8,7 +8,7 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -35,9 +35,9 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -64,13 +64,13 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
projectId: ZId,
workspaceId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -81,9 +81,9 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId,
workspaceId,
},
],
});
@@ -125,9 +125,9 @@ export const getEmailHtmlAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -160,8 +160,8 @@ export const generatePersonalLinksAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -234,8 +234,8 @@ export const updateSingleUseLinksAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -4,13 +4,16 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Confetti } from "@/modules/ui/components/confetti";
export const SuccessMessage = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
@@ -107,9 +107,7 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
isLoading={isLoading}
/>
@@ -71,7 +71,7 @@ export const SummaryPage = ({
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
@@ -111,7 +111,7 @@ export const SummaryPage = ({
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays]);
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
@@ -131,39 +131,13 @@ export const SummaryPage = ({
}
}, [tab, loadInitialDisplays]);
const fetchSummary = useCallback(async () => {
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
const updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
if (updatedSurveySummary?.serverError) {
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
}
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
}, [dateRange, selectedFilter, survey, surveyId]);
const refreshSummary = useCallback(async () => {
setIsLoading(true);
try {
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
} finally {
setIsLoading(false);
}
}, [fetchSummary, loadInitialDisplays, tab]);
useEffect(() => {
return registerAnalysisRefreshHandler(refreshSummary);
}, [refreshSummary, registerAnalysisRefreshHandler]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
(!selectedFilter ||
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!dateRange || (!dateRange.from && !dateRange.to));
if (initialSurveySummary && hasNoFilters) {
@@ -171,11 +145,21 @@ export const SummaryPage = ({
return;
}
const fetchFilteredSummary = async () => {
const fetchSummary = async () => {
setIsLoading(true);
try {
await fetchSummary();
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
} catch (error) {
console.error(error);
} finally {
@@ -183,8 +167,8 @@ export const SummaryPage = ({
}
};
fetchFilteredSummary();
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
fetchSummary();
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -1,18 +1,18 @@
"use client";
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
@@ -23,6 +23,8 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -39,6 +41,8 @@ interface ModalState {
}
export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
user,
publicDomain,
@@ -59,12 +63,9 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { workspace } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const { refreshAnalysisData } = useResponseFilter();
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -76,7 +77,7 @@ export const SurveyAnalysisCTA = ({
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(globalThis.location.search);
const params = new URLSearchParams(window.location.search);
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
@@ -127,7 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
projectId: project.id,
workspaceId: workspace.id,
});
if (result?.data) {
toast.success(
@@ -146,25 +147,6 @@ export const SurveyAnalysisCTA = ({
};
const iconActions = [
{
icon: RefreshCcwIcon,
tooltip: t("common.refresh"),
onClick: async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
await refreshAnalysisData();
toast.success(t("common.data_refreshed_successfully"));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
} finally {
setIsRefreshing(false);
}
},
disabled: isRefreshing,
isVisible: true,
},
{
icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -201,7 +183,7 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown />
<SurveyStatusDropdown environment={environment} survey={survey} />
)}
<IconBar actions={iconActions} />
@@ -230,10 +212,10 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
workspaceCustomScripts={workspace.customHeadScripts}
/>
)}
<SuccessMessage />
<SuccessMessage environment={environment} survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -53,7 +53,7 @@ interface ShareSurveyModalProps {
isFormbricksCloud: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
workspaceCustomScripts?: string | null;
}
export const ShareSurveyModal = ({
@@ -68,7 +68,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
isReadOnly,
isStorageConfigured,
projectCustomScripts,
workspaceCustomScripts,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -193,7 +193,7 @@ export const ShareSurveyModal = ({
title: t("environments.surveys.share.custom_html.nav_title"),
description: t("environments.surveys.share.custom_html.description"),
componentType: CustomHtmlTab,
componentProps: { projectCustomScripts, isReadOnly },
componentProps: { workspaceCustomScripts, isReadOnly },
},
];
@@ -216,7 +216,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
email,
isStorageConfigured,
projectCustomScripts,
workspaceCustomScripts,
]);
const getDefaultActiveId = useCallback(() => {
@@ -88,7 +88,7 @@ const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayC
export const AppTab = () => {
const { t } = useTranslation();
const { environment, project } = useEnvironment();
const { environment, workspace } = useEnvironment();
const { survey } = useSurvey();
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
@@ -98,8 +98,8 @@ export const AppTab = () => {
if (survey.recontactDays !== null) {
return formatRecontactDaysString(survey.recontactDays, t);
}
if (project.recontactDays !== null) {
return formatRecontactDaysString(project.recontactDays, t);
if (workspace.recontactDays !== null) {
return formatRecontactDaysString(workspace.recontactDays, t);
}
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
};
@@ -22,7 +22,7 @@ import {
import { TabToggle } from "@/modules/ui/components/tab-toggle";
interface CustomHtmlTabProps {
projectCustomScripts: string | null | undefined;
workspaceCustomScripts: string | null | undefined;
isReadOnly: boolean;
}
@@ -31,7 +31,7 @@ interface CustomHtmlFormData {
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
}
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
const { t } = useTranslation();
const { survey } = useSurvey();
const [isSaving, setIsSaving] = useState(false);
@@ -101,18 +101,18 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
</div>
{/* Workspace Scripts Preview */}
{projectCustomScripts && (
{workspaceCustomScripts && (
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{projectCustomScripts}
{workspaceCustomScripts}
</pre>
</div>
</div>
)}
{!projectCustomScripts && (
{!workspaceCustomScripts && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
<p className="text-sm text-slate-500">
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
@@ -163,7 +163,6 @@ export const PersonalLinksTab = ({
<UpgradePrompt
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
feature="personal_links"
buttons={[
{
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
@@ -16,19 +16,13 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslation();
const separator = surveyUrl.includes("?") ? "&" : "?";
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${iframeSrc}"
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
return (
<>
<CodeBlock language="html" noMargin>
@@ -54,15 +48,6 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
{t("common.copy_code")}
<CopyIcon />
</Button>
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
<iframe
title={t("common.preview")}
src={previewSrc}
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</>
);
};
@@ -1,8 +1,8 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
@@ -12,12 +12,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), surveyId);
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
const workspace = await getWorkspaceByEnvironmentId(survey.environmentId);
if (!workspace) {
throw new Error("Workspace not found");
}
const styling = getStyling(project, survey);
const styling = getStyling(workspace, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
@@ -2,10 +2,10 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TLanguage } from "@formbricks/types/workspace";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -99,7 +99,7 @@ const mockBaseSurvey: TSurvey = {
createdBy: "user_123",
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
workspaceOverwrites: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -178,74 +178,19 @@ describe("getSurveySummaryMeta", () => {
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
const meta = getSurveySummaryMeta([], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
expect(meta.dropOffPercentage).toBe(0);
expect(meta.ttcAverage).toBe(0);
});
test("uses block-level TTC to avoid multiplying by number of elements", () => {
const surveyWithOneBlockThreeElements: TSurvey = {
...mockBaseSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q3",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q3" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
] as TSurveyElement[],
},
],
questions: [],
};
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b", q3: "c" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
finished: true,
},
] as any;
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
expect(meta.ttcAverage).toBe(5000);
});
});
describe("getSurveySummaryDropOff", () => {
@@ -329,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
@@ -51,32 +51,7 @@ interface TSurveySummaryResponse {
finished: boolean;
}
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[],
displayCount: number,
quotas: TSurveySummary["quotas"]
@@ -85,15 +60,9 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
if (response.ttc?._total) {
ttcResponseCount++;
return acc + responseTtcTotal;
return acc + response.ttc._total;
}
return acc;
}, 0);
@@ -148,16 +117,12 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => {
// Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => {
const blockId = elementIdToBlockId[elementId];
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
responseCounts[elementId]++;
}
});
@@ -1009,8 +974,10 @@ export const getSurveySummary = reactCache(
]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
const [meta, elementSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
return {
meta,
@@ -1094,9 +1061,7 @@ export const getResponsesForSummary = reactCache(
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
id: responsePrisma.id,
data: (responsePrisma.data ?? {}) as TResponseData,
updatedAt: responsePrisma.updatedAt,
...responsePrisma,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
@@ -1105,10 +1070,6 @@ export const getResponsesForSummary = reactCache(
)?.value as string,
}
: null,
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
language: responsePrisma.language,
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
finished: responsePrisma.finished,
};
})
);
@@ -66,6 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -76,7 +78,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation activeId="summary" />
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
@@ -4,13 +4,12 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { capturePostHogEvent } from "@/lib/posthog";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey } 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 { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
@@ -24,38 +23,27 @@ const ZGetResponsesDownloadUrlAction = z.object({
export const getResponsesDownloadUrlAction = authenticatedActionClient
.inputSchema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
const result = await getResponseDownloadFile(
return await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
return result;
});
const ZGetSurveyFilterDataAction = z.object({
@@ -82,9 +70,9 @@ export const getSurveyFilterDataAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -3,9 +3,8 @@
import { useRouter } from "next/navigation";
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 { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -17,9 +16,17 @@ import {
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
export const SurveyStatusDropdown = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
interface SurveyStatusDropdownProps {
environment: TEnvironment;
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
survey: TSurvey;
}
export const SurveyStatusDropdown = ({
environment,
updateLocalSurveyStatus,
survey,
}: SurveyStatusDropdownProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -39,6 +46,10 @@ export const SurveyStatusDropdown = () => {
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
@@ -1,8 +0,0 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -1,10 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -0,0 +1,208 @@
"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>
);
};
@@ -0,0 +1,42 @@
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;
@@ -1,3 +1,3 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;
@@ -1,3 +1,3 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page";
export default AppConnectionPage;
@@ -1,3 +1,3 @@
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
import { GeneralSettingsLoading } from "@/modules/workspaces/settings/general/loading";
export default GeneralSettingsLoading;
@@ -1,3 +1,3 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page";
export default GeneralSettingsPage;
@@ -4,14 +4,13 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration";
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
getWorkspaceIdFromEnvironmentId,
getWorkspaceIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -35,9 +34,9 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -46,12 +45,6 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
return result;
})
);
@@ -73,8 +66,8 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
@@ -18,7 +18,6 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -29,7 +28,6 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -51,8 +49,6 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,11 +12,9 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
@@ -26,20 +24,10 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
@@ -85,34 +73,15 @@ export const ManageIntegration = ({
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("environments.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
<span className="cursor-pointer text-slate-500">
{t("environments.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
@@ -153,7 +122,9 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
</button>
))}
</div>

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