Merge branch 'main' into action-env-to-prod
@@ -157,7 +157,7 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
# DEFAULT_ORGANIZATION_ID=
|
||||
# DEFAULT_ORGANIZATION_ROLE=admin
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
|
||||
# Send new users to customer.io
|
||||
# CUSTOMER_IO_API_KEY=
|
||||
|
||||
@@ -4,9 +4,9 @@ on:
|
||||
workflow_dispatch:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
# schedule:
|
||||
# Runs “At 00:00.” (see https://crontab.guru)
|
||||
# - cron: "0 0 * * *"
|
||||
schedule:
|
||||
# Runs "At 00:00." (see https://crontab.guru)
|
||||
- cron: "0 0 * * *"
|
||||
jobs:
|
||||
cron-weeklySummary:
|
||||
env:
|
||||
|
||||
6
.gitpod.Dockerfile
vendored
@@ -1,6 +0,0 @@
|
||||
FROM gitpod/workspace-full
|
||||
|
||||
# Install custom tools, runtime, etc.
|
||||
RUN brew install yq
|
||||
|
||||
RUN pnpm install turbo --global
|
||||
74
.gitpod.yml
@@ -1,74 +0,0 @@
|
||||
tasks:
|
||||
- name: demo
|
||||
init: |
|
||||
gp sync-await init-install &&
|
||||
bash .gitpod/setup-demo.bash
|
||||
command: |
|
||||
cd apps/demo &&
|
||||
cp .env.example .env &&
|
||||
sed -i -r "s#^(NEXT_PUBLIC_FORMBRICKS_API_HOST=).*#\1 $(gp url 3000)#" .env &&
|
||||
gp sync-await init &&
|
||||
turbo --filter "@formbricks/demo" go
|
||||
|
||||
- name: Init Formbricks
|
||||
init: |
|
||||
cp .env.example .env &&
|
||||
bash .gitpod/init.bash &&
|
||||
turbo --filter "@formbricks/js" build &&
|
||||
gp sync-done init-install
|
||||
command: |
|
||||
gp sync-done init &&
|
||||
gp tasks list &&
|
||||
gp ports await 3002 && gp ports await 3000 && gp open apps/demo/.env && gp preview $(gp url 3002) --external
|
||||
|
||||
- name: web
|
||||
init: |
|
||||
gp sync-await init-install &&
|
||||
bash .gitpod/setup-web.bash &&
|
||||
turbo --filter "@formbricks/database" db:down
|
||||
command: |
|
||||
gp sync-await init &&
|
||||
cp .env.example .env &&
|
||||
sed -i -r "s#^(WEBAPP_URL=).*#\1 $(gp url 3000)#" .env &&
|
||||
RANDOM_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
sed -i 's/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY='"$RANDOM_ENCRYPTION_KEY"'/' .env
|
||||
turbo --filter "@formbricks/web" go
|
||||
|
||||
image:
|
||||
file: .gitpod.Dockerfile
|
||||
|
||||
ports:
|
||||
- port: 3000
|
||||
visibility: public
|
||||
onOpen: open-browser
|
||||
- port: 3001
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 3002
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 5432
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 1025
|
||||
visibility: public
|
||||
onOpen: ignore
|
||||
- port: 8025
|
||||
visibility: public
|
||||
onOpen: open-browser
|
||||
|
||||
github:
|
||||
prebuilds:
|
||||
master: true
|
||||
pullRequests: true
|
||||
addComment: true
|
||||
|
||||
vscode:
|
||||
extensions:
|
||||
- "ban.spellright"
|
||||
- "bradlc.vscode-tailwindcss"
|
||||
- "DavidAnson.vscode-markdownlint"
|
||||
- "dbaeumer.vscode-eslint"
|
||||
- "esbenp.prettier-vscode"
|
||||
- "Prisma.prisma"
|
||||
- "yzhang.markdown-all-in-one"
|
||||
@@ -18,7 +18,7 @@ Ready to dive into the code and make a real impact? Here's your path:
|
||||
|
||||
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
|
||||
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://formbricks.com/docs/developer-docs/contributing/gitpod) or use [Codespaces](https://formbricks.com/docs/developer-docs/contributing/codespaces)
|
||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
|
||||
|
||||
1. **Tweak and Transform:** Work your coding magic and apply your changes.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"lucide-react": "0.452.0",
|
||||
"next": "14.2.15",
|
||||
"next": "14.2.16",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
|
||||
@@ -14,11 +14,7 @@ We are so happy that you are interested in contributing to Formbricks 🤗 There
|
||||
|
||||
- **Issues**: Spotted a bug? Has deployment gone wrong? Do you have user feedback? [Raise an issue](https://github.com/formbricks/formbricks/issues/new/choose) for the fastest response.
|
||||
- **Feature requests**: Raise an issue for these and tag it as an Enhancement. We love every idea. Please [open a feature request](https://github.com/formbricks/formbricks/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml&title=%5BFEATURE%5D) clearly describing the problem you want to solve.
|
||||
- **How we Code at Formbricks**: [View this Notion document](https://formbricks.notion.site/How-we-code-at-Formbricks-8bcaa0304a20445db4871831149c0cf5?pvs=4) and understand the coding practises we follow so that you can adhere to them for uniformity.
|
||||
- **Creating a PR**: Please fork the repository, make your changes and create a new pull request if you want to make an update.
|
||||
- **E2E Tests**: [Understand how we write E2E tests](https://formbricks.notion.site/Formbricks-End-to-End-Tests-06dc830d71604deaa8da24714540f7ab?pvs=4) and make sure to consider writing a test whenever you ship a feature.
|
||||
- **New Question Types**:[Follow this guide](https://formbricks.notion.site/Guidelines-for-Implementing-a-New-Question-Type-9ac0d1c362714addb24b9abeb326d1c1?pvs=4) to keep everything in mind when you want to add a new question type.
|
||||
- **How to create a service**: [Read this document to understand how we use services](https://formbricks.notion.site/How-to-create-a-service-8e0c035704bb40cb9ea5e5beeeeabd67?pvs=4). This is particulalry important when you need to write a new one.
|
||||
- **Creating a PR**: Please fork the repository, make your changes and create a new pull request if you want to make an update. Please talk to us first before starting development of more complex features. Small fixes are always welcome!
|
||||
|
||||
## Talk to us first
|
||||
|
||||
@@ -34,8 +30,8 @@ Once you open a PR, you will get a message from the CLA bot to fill out the form
|
||||
|
||||
We currently officially support the below methods to set up your development environment for Formbricks:
|
||||
|
||||
- [Gitpod](/developer-docs/contributing/gitpod)
|
||||
- [GitHub Codespaces](/developer-docs/contributing/codespaces)
|
||||
- [Gitpod](https://gitpod.io)
|
||||
- [GitHub Codespaces](https://github.com/features/codespaces)
|
||||
- [Local Machine Setup](#local-machine-setup)
|
||||
|
||||
Both Gitpod and GitHub Codespaces have a **generous free tier** to explore and develop. For junior developers we suggest using either of these, because you can dive into coding within minutes, not hours.
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 130 KiB |
@@ -1,172 +0,0 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import GitpodAuth from "./images/auth.webp";
|
||||
import GitpodNewWorkspace from "./images/new-workspace.webp";
|
||||
import GitpodPorts from "./images/ports.webp";
|
||||
import GitpodPreparing from "./images/preparing.webp";
|
||||
import GitpodRunning from "./images/running.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Formbricks Open Source Contribution Guide: How to Enhance yourself and Contribute to Formbricks",
|
||||
description:
|
||||
"Join the Formbricks community and learn how to effectively contribute. From raising issues and feature requests to creating PRs, discover the best practices and communicate with our responsive team on Discord",
|
||||
};
|
||||
|
||||
#### Contributing
|
||||
|
||||
# Gitpod Guide
|
||||
|
||||
**Building custom image for the workspace:**
|
||||
- This includes : Installing `yq` and `turbo` globally before the workspace starts. This is accomplished within the `.gitpod.Dockerfile` along with starting upon a base custom image building on [workspace-full](https://hub.docker.com/r/workspace-full/dockerfile).
|
||||
|
||||
**Initialization of Formbricks:**
|
||||
- During the prebuilds phase, we initialize Formbricks by performing the following tasks:
|
||||
1. Setting up environment variables.
|
||||
2. Installing monorepo dependencies.
|
||||
3. Installing Docker images by extracting them from the `packages/database/docker-compose.yml` file.
|
||||
4. Building the @formbricks/js component.
|
||||
- When the workspace starts:
|
||||
1. Wait for the web and demo apps to launch on Gitpod. This automatically opens the `apps/demo/.env` file. Utilize dynamic localhost URLs (e.g., `localhost:3000` for signup and `localhost:8025` for email confirmation) to configure `NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID`. After creating your account and finding the `ID` in the URL at `localhost:3000`, replace `YOUR_ENVIRONMENT_ID` in the `.env` file located in `app/demo`.
|
||||
|
||||
**Web Component Initialization:**
|
||||
- We initialize the @formbricks/web component during prebuilds. This involves:
|
||||
1. Installing build dependencies for the `@formbricks/web#go` task from turbo.json in prebuilds to save time.
|
||||
2. Starting PostgreSQL and Mailhog containers for running migrations in prebuilds.
|
||||
3. To prevent the "Init" task from running indefinitely due to prebuild rules, a cleanup `docker compose down` step i.e. `db:down` is added to `turbo.json`. This step is designed to halt the execution of containers that are currently running.
|
||||
- When the workspace starts:
|
||||
1. Initializing environment variables.
|
||||
2. Replacing `NEXT_PUBLIC_WEBAPP_URL` to take in Gitpod URL's ports when running on VSCode browser.
|
||||
3. Starting the `@formbricks/web` dev environment.
|
||||
|
||||
**Demo Component Initialization:**
|
||||
- Similar to the web component, the demo component is also initialized during prebuilds. This includes:
|
||||
1. Installing build dependencies for the `formbricks/demo#go` task from turbo.json in prebuilds to save time.
|
||||
2. Caching hits and replaying builds from the `@formbricks/js` component.
|
||||
- When the workspace starts:
|
||||
1. Initializing environment variables.
|
||||
2. Replaces `NEXT_PUBLIC_FORMBRICKS_API_HOST` to take in Gitpod URL's ports when running on VSCode browser.
|
||||
3. Starting the `@formbricks/demo` dev environment.
|
||||
|
||||
**Github Prebuilds Configuration:**
|
||||
- This configures Github Prebuilds for the master branch, pull requests, and adding comments. This helps automate the prebuild process for the specified branches and actions.
|
||||
|
||||
**VSCode Extensions:**
|
||||
- This includes a list of VSCode extensions that are added to the configuration when using Gitpod. These extensions can enhance the development experience within Gitpod.
|
||||
|
||||
### 1. Browser Redirection
|
||||
|
||||
After clicking the one-click setup button, Gitpod will open a new tab or window. Please ensure that your browser allows redirection to successfully access the services:
|
||||
|
||||
### 2. Authorizing in Gitpod
|
||||
|
||||
<MdxImage
|
||||
src={GitpodAuth}
|
||||
alt="Gitpod Auth Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- This is the Gitpod Authentication Page. It appears when you click the "Open in GitPod" button and Gitpod
|
||||
needs to authenticate your access to the workspace. Click on 'Continue With Github' to authorize your GitPod
|
||||
session.
|
||||
|
||||
### 3. Creating a New Workspace
|
||||
|
||||
<MdxImage
|
||||
src={GitpodNewWorkspace}
|
||||
alt="Gitpod New workspace Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- After authentication, Gitpod asks to create a new workspace for you. This page displays the configurations
|
||||
of your workspace. - You can use either choose either VS Code Browser or VS Code Desktop editor with the
|
||||
'Standard Class' for your workspace class. - If you opt for the VS Code Desktop, follow the following steps 1.
|
||||
Gitpod will prompt you to grant access to the VSCode app. Once approved, install the GitPod extension from the
|
||||
VSCode Marketplace and follow the prompts to authorize the integration. 2. Change the `WEBAPP_URL` to
|
||||
`https://localhost:3000`
|
||||
|
||||
### 4. Gitpod preparing the created Workspace
|
||||
|
||||
<MdxImage
|
||||
src={GitpodPreparing}
|
||||
alt="Gitpod Preparing workspace Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- Gitpod is preparing your workspace with all the necessary dependencies and configurations. You will see this
|
||||
page while Gitpod sets up your development environment.
|
||||
|
||||
### 5. Gitpod running the Workspace
|
||||
|
||||
<MdxImage
|
||||
src={GitpodRunning}
|
||||
alt="Gitpod Running Workspace Page"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
- Once the workspace is fully prepared, voila, it enters the running state. You can start working on your
|
||||
project in this environment.
|
||||
|
||||
### Ports and Services
|
||||
|
||||
Here are the ports and corresponding URLs for the services within your Gitpod environment:
|
||||
|
||||
- **Port 3000**:
|
||||
|
||||
- **Service**: Demo App
|
||||
- **Description**: This port hosts the demo application of your project. You can access and interact with your application's demo by navigating to this port.
|
||||
|
||||
- **Port 3001**:
|
||||
|
||||
- **Service**: Formbricks website
|
||||
- **Description**: This port hosts the [Formbricks](https://formbricks.com) website, which contains documents, pricing, blogs, best practices, and concierge service.
|
||||
|
||||
- **Port 3002**:
|
||||
|
||||
- **Service**: Formbricks In-product Survey Demo App
|
||||
- **Description**: This app helps you test your app & website surveys. You can create and test user actions, create and update user attributes, etc.
|
||||
|
||||
- **Port 5432**:
|
||||
|
||||
- **Service**: PostgreSQL Database Server
|
||||
- **Description**: The PostgreSQL DB is hosted on this port.
|
||||
|
||||
- **Port 1025**:
|
||||
|
||||
- **Service**: SMTP server
|
||||
- **Description**: SMTP Server for sending and receiving email messages. This server is responsible for handling email communication.
|
||||
|
||||
- **Port 8025**:
|
||||
- **Service**: Mailhog
|
||||
|
||||
### Accessing port URLs
|
||||
|
||||
1. **Direct URL Composition**:
|
||||
|
||||
- You can access the dedicated port URL by pre-pending the port number to the workspace URL.
|
||||
- For example, if you want to access port 3000, you can use the URL format: `3000-yourworkspace.ws-eu45.gitpod.io`.
|
||||
|
||||
2. **Using [gp CLI](https://www.gitpod.io/docs/references-cli)**:
|
||||
|
||||
- Gitpod provides a convenient command, `gp url`, to quickly retrieve the URL for a specific port.
|
||||
- Simply use the command followed by the desired port number. For example, to get the URL for port 3000, run: `gp url 3000`.
|
||||
|
||||
3. **Listing All Open Port URLs**:
|
||||
|
||||
- If you prefer to see a list of all open port URLs at once, you can use the `gp ports list` command.
|
||||
- Running this command will display a list of ports along with their corresponding URLs.
|
||||
|
||||
4. **Viewing All Ports in Panel**:
|
||||
|
||||
- Gitpod also offers a user-friendly 'Ports' tab in the Gitpod panel.
|
||||
- Click on the 'Ports' tab to view a list of all open ports and their respective URLs.
|
||||
|
||||
{" "}
|
||||
|
||||
<MdxImage
|
||||
src={GitpodPorts}
|
||||
alt="Gitpod Ports tab"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
These URLs and port numbers represent various services and endpoints within your Gitpod environment. You can access and interact with these services by the Port URL for the respective service.
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 20 KiB |
@@ -6,71 +6,98 @@ import IndvInvite from "./images/individual-invite.webp";
|
||||
import MenuItem from "./images/organization-settings-menu.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Organization Access Roles",
|
||||
title: "User Management",
|
||||
description:
|
||||
"Assign different roles to organization members to grant them specific rights like creating surveys, viewing responses, or managing organization members.",
|
||||
};
|
||||
|
||||
# Organization Access Roles
|
||||
|
||||
Assign different roles to organization members to grant them specific rights like creating surveys, viewing responses, or managing organization members.
|
||||
Learn about the different organization-level and team-level roles and how they affect permissions in Formbricks.
|
||||
|
||||
## Memberships
|
||||
|
||||
Permissions in Formbricks are broadly handled using organization-level roles, which apply to all teams and projects in the organization. Users on a self-hosting and Enterprise plan, have access to team-level roles, which enable more granular permissions.
|
||||
|
||||
<Note>
|
||||
Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free**
|
||||
and **Startup** plan in the Cloud you can invite unlimited organization members as `Admins`.
|
||||
and **Startup** plan in the Cloud you can invite unlimited organization members as `Owner`.
|
||||
</Note>
|
||||
|
||||
Here are the different access permissions, ranked from highest to lowest access
|
||||
|
||||
1. Owner
|
||||
2. Admin
|
||||
3. Developer
|
||||
4. Editor
|
||||
5. Viewer
|
||||
2. Manager
|
||||
3. Billing
|
||||
4. Member
|
||||
|
||||
### Organisational level
|
||||
|
||||
All users and their organization-level roles are listed in **Organization Settings > General**. Users can hold any of the following org-level roles:
|
||||
|
||||
- **Billing** users can manage payment and compliance details in the organization.
|
||||
- **Org Members** can view most data in the organization and act in the products they are members of. They cannot join products on their own and need to be assigned.
|
||||
- **Org Managers** have full management access to all teams and products. They can also manage the organization's membership. Org Managers can perform Team Admin actions without needing to join the team. They cannot change other organization settings.
|
||||
- **Org Owners** have full access to the organization, its data, and settings. Org Owners can perform Team Admin actions without needing to join the team.
|
||||
|
||||
### Permissions at product level
|
||||
|
||||
- **read**: read access to all resources (except settings) in the product.
|
||||
- **read & write**: read & write access to all resources (except settings) in the product.
|
||||
- **manage**: read & write access to all resources including settings in the product.
|
||||
|
||||
### Team-level Roles
|
||||
|
||||
- **Team Contributors** can view and act on surveys and responses.
|
||||
- **Team Admins** have additional permissions to manage their team's membership and products. These permissions are granted at the team-level, and don't apply to teams where they're not a Team Admin.
|
||||
|
||||
For more information on user roles & permissions, see below:
|
||||
|
||||
| | Owner | Admin | Editor | Developer | Viewer |
|
||||
| -------------------------------- | ----- | ----- | ------ | --------- | ------ |
|
||||
| **Organization** | | | | | |
|
||||
| Update organization | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Delete organization | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| Add new Member | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Delete Member | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Update Member Access | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Update Billing | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **Product** | | | | | |
|
||||
| Create Product | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Update Product Name | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| Update Product Recontact Options | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Update Look & Feel | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Update Survey Languages | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Delete Product | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Surveys** | | | | | |
|
||||
| Create New Survey | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Edit Survey | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Delete Survey | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| View survey results | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Response** | | | | | |
|
||||
| Delete response | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Add tags on response | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Edit tags on response | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Actions** | | | | | |
|
||||
| Create Action | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Update Action | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Delete Action | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **API Keys** | | | | | |
|
||||
| Create API key | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Update API key | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Delete API key | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Tags** | | | | | |
|
||||
| Create tags | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Update tags | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Delete tags | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **People** | | | | | |
|
||||
| Delete Person | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Integrations** | | | | | |
|
||||
| Manage Integrations | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| | Owner | Manager | Billing | Member |
|
||||
| -------------------------------- | ----- | ------- | ------- | ------ |
|
||||
| **Organization** | | | | |
|
||||
| Update organization | ✅ | ❌ | ❌ | ❌ |
|
||||
| Delete organization | ✅ | ❌ | ❌ | ❌ |
|
||||
| Add new Member | ✅ | ✅ | ❌ | ❌ |
|
||||
| Delete Member | ✅ | ✅ | ❌ | ❌ |
|
||||
| Update Member Access | ✅ | ✅ | ❌ | ❌ |
|
||||
| Update Billing | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Product** | | | | |
|
||||
| Create Product | ✅ | ✅ | ❌ | ❌ |
|
||||
| Update Product Name | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| Update Product Recontact Options | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| Update Look & Feel | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| Update Survey Languages | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| Delete Product | ✅ | ✅ | ❌ | ❌ |
|
||||
| **Surveys** | | | | |
|
||||
| Create New Survey | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Edit Survey | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Delete Survey | ✅ | ✅ | ❌ | ✅\* |
|
||||
| View survey results | ✅ | ✅ | ❌ | ✅ |
|
||||
| **Response** | | | | |
|
||||
| Delete response | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Add tags on response | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Edit tags on response | ✅ | ✅ | ❌ | ✅\* |
|
||||
| **Actions** | | | | |
|
||||
| Create Action | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Update Action | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Delete Action | ✅ | ✅ | ❌ | ✅\* |
|
||||
| **API Keys** | | | | |
|
||||
| Create API key | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| Update API key | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| Delete API key | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| **Tags** | | | | |
|
||||
| Create tags | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Update tags | ✅ | ✅ | ❌ | ✅\* |
|
||||
| Delete tags | ✅ | ✅ | ❌ | ✅\*\* |
|
||||
| **People** | | | | |
|
||||
| Delete Person | ✅ | ✅ | ❌ | ✅\* |
|
||||
| **Integrations** | | | | |
|
||||
| Manage Integrations | ✅ | ✅ | ❌ | ✅\* |
|
||||
|
||||
\* - for the read & write permissions team members
|
||||
|
||||
\*\* - for the manage permissions team members
|
||||
|
||||
## Inviting organization members
|
||||
|
||||
@@ -107,7 +134,7 @@ There are two ways to invite organization members: One by one or in bulk.
|
||||
|
||||
<Note>
|
||||
Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free**
|
||||
and **Startup** plan in the Cloud you can invite unlimited organization members as `Admins`.
|
||||
and **Startup** plan in the Cloud you can invite unlimited organization members as `Owners`.
|
||||
</Note>
|
||||
|
||||
Formbricks sends an email to the organization member with an invitation link. The organization member can accept the invitation or create a new account by clicking on the link.
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
@@ -1,131 +1,75 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import StepEight from "./images/StepEight.webp";
|
||||
import StepFive from "./images/StepFive.webp";
|
||||
import StepFour from "./images/StepFour.webp";
|
||||
import SurveyEmbed from "@/components/SurveyEmbed";
|
||||
import StepOne from "./images/StepOne.webp";
|
||||
import StepSeven from "./images/StepSeven.webp";
|
||||
import StepSix from "./images/StepSix.webp";
|
||||
import StepThree from "./images/StepThree.webp";
|
||||
import StepTwo from "./images/StepTwo.webp";
|
||||
|
||||
export const metadata = {
|
||||
title: "Recall Functionality for Formbricks Surveys",
|
||||
title: "Recall Data in Formbricks Surveys",
|
||||
description:
|
||||
"Enhance your surveys with the Recall Functionality to create more engaging and personalized experiences. This feature allows users to dynamically include responses from previous answers in subsequent questions or descriptions, adapting the survey content based on individual responses.",
|
||||
};
|
||||
"Personalize your surveys by dynamically inserting data from URL parameters or previous answers into questions and descriptions. The Recall Data feature helps create engaging, adaptive survey experiences tailored to each respondent."};
|
||||
|
||||
# Recall Functionality
|
||||
# Recall Data
|
||||
|
||||
Enhance your surveys with the Recall Functionality to create more engaging and personalized experiences. This feature allows users to dynamically include responses from previous answers in subsequent questions or descriptions, adapting the survey content based on individual responses.
|
||||
Personalize your surveys by dynamically inserting data from URL parameters or previous answers into questions and descriptions. The Recall Data feature helps create engaging, adaptive survey experiences tailored to each respondent.
|
||||
|
||||
### **How to Insert Recall References**
|
||||
## Recall Sources
|
||||
You can recall data from the following sources:
|
||||
|
||||
- The response of a previous question
|
||||
- The URL using a [Hidden Field](/docs/global/hidden-fields)
|
||||
|
||||
## Recalling from a previous question
|
||||
|
||||
<Note>
|
||||
The recall functionality is disabled on the first question of the survey since there’s no preceding question
|
||||
to recall
|
||||
to recall data from.
|
||||
</Note>
|
||||
|
||||
**Pre-requisite:** Ensure the answer you wish to recall precedes the question in which it will be recalled. Here’s an example of setting up the first question:
|
||||
### **Pre-requisite**
|
||||
|
||||
<MdxImage
|
||||
src={StepOne}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
### **Steps to Initiate Recall**
|
||||
|
||||
1. **Initiate Recall**: In the survey editor, type **`@`** in the question or description field where you want to insert a recall. This triggers a dropdown menu listing all preceding questions.
|
||||
|
||||
<MdxImage
|
||||
src={StepTwo}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
2. **Select a Question**: Navigate through the dropdown to choose your question. The first question is automatically focused, making it easy to select by pressing **`ENTER`**. Once selected, the question becomes a linked placeholder within the text field.
|
||||
|
||||
### **Configuring Fallback Options**
|
||||
|
||||
To ensure the survey remains coherent when a response is missing (or the question is optional), you can set a fallback option.
|
||||
Ensure the answer you wish to recall precedes the question in which it will be recalled. Here’s an example of setting up the first question:
|
||||
|
||||
<MdxImage
|
||||
src={StepThree}
|
||||
alt="Choose a link survey template"
|
||||
alt="Survey setup example with link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
1. **Access Fallback Settings**: Click the linked placeholder to open the configuration pop-up for fallback settings.
|
||||
2. **Set Fallback Text**: Input the fallback text that should appear if the recalled question lacks a response. This text ensures smooth survey progression.
|
||||
### **Step 1: Recall Data**
|
||||
|
||||
### **Example of Recall Usage**
|
||||
|
||||
For example, you might structure a survey about favorite fruits as follows:
|
||||
|
||||
- **Question 1**: "What is your favorite fruit?"
|
||||
- **Question 2**: "Why do you like `@What is your favorite fruit?` so much?"
|
||||
|
||||
If "Question 1" is unanswered, you can set a fallback like "the fruit" to make "Question 2" read: "Why do you like the fruit so much?"
|
||||
|
||||
### **User Experience**
|
||||
|
||||
If a respondent answers “Mango” to the first question, the recall functionality automatically adjusts the following question to "Why is Mango your favorite?”
|
||||
Type **`@`** in the question or description field where you want to insert a recall. This triggers a dropdown menu listing all preceding questions. Select the question you want to recall data from.
|
||||
|
||||
<MdxImage
|
||||
src={StepFour}
|
||||
alt="Choose a link survey template"
|
||||
src={StepTwo}
|
||||
alt="Dropdown menu for recalling data in survey"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
As you can see, the questions that use this recall automatically use the response value:
|
||||
### **Step 2: Set a Fallback**
|
||||
|
||||
To ensure the survey remains coherent when a response is missing (or the question is optional), you should set a fallback option.
|
||||
|
||||
<MdxImage
|
||||
src={StepFive}
|
||||
alt="Choose a link survey template"
|
||||
src={StepOne}
|
||||
alt="Setting fallback option in survey question"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
<MdxImage
|
||||
src={StepSix}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
## Recalling from the URL
|
||||
1. Create a hidden field, [here is how →](/docs/global/hidden-fields)
|
||||
2. Use the `@` symbol in a question or description to recall the value of the hidden field
|
||||
3. Set a fallback in case the hidden field is not being filled by a URL parameter
|
||||
4. Use [Data Prefilling](/docs/link-surveys/data-prefilling) to set the hidden field value when the survey is accessed
|
||||
|
||||
### **Visual Indicators in UI**
|
||||
|
||||
When setting up your questions, the UI will visually indicate where recalls are used:
|
||||
|
||||
- **Recall Indicators**: Linked questions will be highlighted with a light grey background in the survey editor, making it clear where dynamic content is being utilized.
|
||||
- **Fallback Indicators**: Any fallbacks set will also be displayed in a subtle yet distinct manner, ensuring you are aware of all conditional text paths in your survey.
|
||||
## Live Demo
|
||||
|
||||
### Response Summary Page
|
||||
|
||||
On the Formbricks dashboard, summary responses and individual response cards reflect recalled information, ensuring data coherence and enhancing analysis.
|
||||
|
||||
- Summary Tab
|
||||
|
||||
<MdxImage
|
||||
src={StepSeven}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
|
||||
- Response Card:
|
||||
|
||||
<MdxImage
|
||||
src={StepEight}
|
||||
alt="Choose a link survey template"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
/>
|
||||
<SurveyEmbed surveyUrl="https://app.formbricks.com/s/cm393eiiq0001kxphzc6lbbku" />
|
||||
|
||||
## **Conclusion**
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ These variables are present inside your machine’s docker-compose file. Restart
|
||||
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
|
||||
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
|
||||
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | owner |
|
||||
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
|
||||
@@ -8,6 +8,123 @@ export const metadata = {
|
||||
|
||||
# Migration Guide
|
||||
|
||||
## v2.7
|
||||
|
||||
<Note>
|
||||
This release sets the foundation for our upcoming AI features, currently in private beta. Formbricks now
|
||||
requires the `pgvector` extension to be installed in the PostgreSQL database. For users of our one-click
|
||||
setup, simply use the `pgvector/pgvector:pg15` image instead of `postgres:15-alpine`.
|
||||
</Note>
|
||||
|
||||
Formbricks v2.7 includes all the features and improvements developed by the community during hacktoberfest 2024. Additionally we introduce an advanced team-based access control system (requires Formbricks Enterprise Edition).
|
||||
|
||||
### Additional Updates
|
||||
|
||||
If you previously used organization-based access control (enterprise feature) as well as the `DEFAULT_ORGANIZATION_ROLE` environment variable, make sure to update the value to one of the following roles: `owner`, `manager`, `member`. Read more about the new roles in the [Docs](/global/access-roles).
|
||||
|
||||
### Steps to Migrate
|
||||
|
||||
This guide is for users who are self-hosting Formbricks using our one-click setup. If you are using a different setup, you might adjust the commands accordingly.
|
||||
|
||||
To run all these steps, please navigate to the `formbricks` folder where your `docker-compose.yml` file is located.
|
||||
|
||||
1. **Backup your Database**: This is a crucial step. Please make sure to backup your database before proceeding with the upgrade. You can use the following command to backup your database:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Backup Postgres">
|
||||
|
||||
```bash
|
||||
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.7_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
<Note>
|
||||
If you run into “No such container”, use `docker ps` to find your container name, e.g.
|
||||
`formbricks_postgres_1`.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
If you prefer storing the backup as an `*.sql` file remove the `-Fc` (custom format) option. In case of a
|
||||
restore scenario you will need to use `psql` then with an empty `formbricks` database.
|
||||
</Note>
|
||||
|
||||
2. If you use an older `docker-compose.yml` file from the one-click setup, modify it to use the `pgvector/pgvector:pg15` image instead of `postgres:15-alpine`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
```
|
||||
|
||||
3. Pull the latest version of Formbricks:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the containers">
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
4. Stop the running Formbricks instance & remove the related containers:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the containers">
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
5. Restarting the containers with the latest version of Formbricks:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Restart the containers">
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
6. Now let's migrate the data to the latest schema:
|
||||
|
||||
<Note>To find your Docker Network name for your Postgres Database, find it using `docker network ls`</Note>
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Migrate the data">
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/formbricks/data-migrations:latest && \
|
||||
docker run --rm \
|
||||
--network=formbricks_default \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
|
||||
-e UPGRADE_TO_VERSION="v2.7" \
|
||||
ghcr.io/formbricks/data-migrations:v2.7.0
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
The above command will migrate your data to the latest schema. This is a crucial step to migrate your existing data to the new structure. Only if the script runs successful, changes are made to the database. The script can safely run multiple times.
|
||||
|
||||
7. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
|
||||
|
||||
## v2.6
|
||||
|
||||
Formbricks v2.6 introduces advanced logic jumps for surveys, allowing you to add more advanced branching logic to your surveys including variables, and/or conditions and many more. This release also includes a lot of bug fixes, big performance improvements to website and app surveys and a lot of stability improvements.
|
||||
|
||||
@@ -126,7 +126,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
|
||||
],
|
||||
},
|
||||
{ title: "Access Roles", href: "/global/access-roles" },
|
||||
{ title: "User Management", href: "/global/access-roles" },
|
||||
{ title: "Styling Theme", href: "/global/styling-theme" },
|
||||
],
|
||||
},
|
||||
@@ -156,8 +156,6 @@ export const navigation: Array<NavGroup> = [
|
||||
title: "Contributing",
|
||||
children: [
|
||||
{ title: "Get started", href: "/developer-docs/contributing/get-started" },
|
||||
{ title: "Codespaces", href: "/developer-docs/contributing/codespaces" },
|
||||
{ title: "Gitpod", href: "/developer-docs/contributing/gitpod" },
|
||||
{ title: "Troubleshooting", href: "/developer-docs/contributing/troubleshooting" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"lucide-react": "0.452.0",
|
||||
"mdast-util-to-string": "4.0.0",
|
||||
"mdx-annotations": "0.1.4",
|
||||
"next": "14.2.15",
|
||||
"next": "14.2.16",
|
||||
"next-plausible": "3.12.2",
|
||||
"next-seo": "6.6.0",
|
||||
"next-sitemap": "4.2.3",
|
||||
|
||||
@@ -40,7 +40,7 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
|
||||
await inviteOrganizationMemberAction({
|
||||
organizationId: organization.id,
|
||||
email: data.email,
|
||||
role: "developer",
|
||||
role: "member",
|
||||
inviteMessage: data.inviteMessage,
|
||||
});
|
||||
toast.success("Invite sent successful");
|
||||
|
||||
@@ -27,7 +27,7 @@ const Page = async ({ params }: InvitePageProps) => {
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
|
||||
if (!membership || (membership.role !== "owner" && membership.role !== "manager")) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
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";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createSurveyAction } from "@/modules/surveys/components/TemplateList/actions";
|
||||
import { ActivityIcon, ShoppingCartIcon, UsersIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { createSurveyAction } from "@formbricks/ui/components/TemplateList/actions";
|
||||
|
||||
interface XMTemplateListProps {
|
||||
product: TProduct;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getProductByEnvironmentId, getProducts } from "@formbricks/lib/product/service";
|
||||
import { getProductByEnvironmentId, getUserProducts } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Header } from "@formbricks/ui/components/Header";
|
||||
@@ -39,7 +39,7 @@ const Page = async ({ params }: XMTemplatePageProps) => {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
const products = await getProducts(organizationId);
|
||||
const products = await getUserProducts(session.user.id, organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
|
||||
48
apps/web/app/(app)/(onboarding)/lib/onboarding.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getTeamsByOrganizationId = reactCache(
|
||||
(organizationId: string): Promise<TOrganizationTeam[] | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const productTeams = teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
}));
|
||||
|
||||
return productTeams;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getTeamsByOrganizationId-${organizationId}`],
|
||||
{
|
||||
tags: [teamCache.tag.byOrganizationId(organizationId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AiOutlineDiscord } from "react-icons/ai";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { ProfileAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/components/DropdownMenu";
|
||||
|
||||
interface LandingSidebarProps {
|
||||
isMultiOrgEnabled: boolean;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
organizations: TOrganization[];
|
||||
}
|
||||
|
||||
export const LandingSidebar = ({
|
||||
isMultiOrgEnabled,
|
||||
user,
|
||||
organization,
|
||||
organizations,
|
||||
}: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
href: "https://formbricks.com/docs",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.join_discord"),
|
||||
href: "https://formbricks.com/discord",
|
||||
target: "_blank",
|
||||
icon: AiOutlineDiscord,
|
||||
},
|
||||
];
|
||||
|
||||
const currentOrganizationId = organization?.id;
|
||||
const currentOrganizationName = capitalizeFirstLetter(organization?.name);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"w-sidebar-collapsed 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"
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
<>
|
||||
<div>
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="max-w-28 truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
|
||||
</>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link href={link.href} target={link.target} className="flex w-full items-center">
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await formbricksLogout();
|
||||
}}
|
||||
icon={<LogOutIcon className="h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Organization Switch */}
|
||||
|
||||
{(isMultiOrgEnabled || organizations.length > 1) && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="rounded-lg">
|
||||
<div>
|
||||
<p>{currentOrganizationName}</p>
|
||||
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentOrganizationId}
|
||||
onValueChange={(organizationId) =>
|
||||
handleEnvironmentChangeByOrganization(organizationId)
|
||||
}>
|
||||
{sortedOrganizations.map((organization) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={organization.id}
|
||||
className="cursor-pointer rounded-lg"
|
||||
key={organization.id}>
|
||||
{organization.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getUserProducts } from "@formbricks/lib/product/service";
|
||||
|
||||
const LandingLayout = async ({ children, params }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
|
||||
|
||||
if (!membership) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const products = await getUserProducts(session.user.id, params.organizationId);
|
||||
|
||||
if (products.length !== 0) {
|
||||
const firstProduct = products[0];
|
||||
const environments = await getEnvironments(firstProduct.id);
|
||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||
|
||||
if (prodEnvironment) {
|
||||
return redirect(`/environments/${prodEnvironment.id}/`);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default LandingLayout;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { Header } from "@formbricks/ui/components/Header";
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const t = await getTranslations();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) return notFound();
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) return notFound();
|
||||
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
const { features } = await getEnterpriseLicense();
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
<LandingSidebar
|
||||
user={user}
|
||||
organization={organization}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizations={organizations}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_products_warning_title")}
|
||||
subtitle={t("organizations.landing.no_products_warning_subtitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
@@ -27,9 +26,6 @@ const ProductOnboardingLayout = async ({ children, params }) => {
|
||||
throw AuthorizationError;
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
|
||||
if (!membership || membership.role === "viewer") return notFound();
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
|
||||
const OnboardingLayout = async ({ children, params }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
|
||||
const { isMember, isBilling } = getAccessFlags(membership?.role);
|
||||
if (isMember || isBilling) return notFound();
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default OnboardingLayout;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { GlobeIcon, GlobeLockIcon, LinkIcon, XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getUserProducts } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Header } from "@formbricks/ui/components/Header";
|
||||
|
||||
@@ -12,6 +15,11 @@ interface ChannelPageProps {
|
||||
}
|
||||
|
||||
const Page = async ({ params }: ChannelPageProps) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
const channelOptions = [
|
||||
{
|
||||
@@ -38,7 +46,7 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
},
|
||||
];
|
||||
|
||||
const products = await getProducts(params.organizationId);
|
||||
const products = await getUserProducts(session.user.id, params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getUserProducts } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Header } from "@formbricks/ui/components/Header";
|
||||
|
||||
@@ -12,6 +15,11 @@ interface ModePageProps {
|
||||
}
|
||||
|
||||
const Page = async ({ params }: ModePageProps) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
const channelOptions = [
|
||||
{
|
||||
@@ -28,7 +36,7 @@ const Page = async ({ params }: ModePageProps) => {
|
||||
},
|
||||
];
|
||||
|
||||
const products = await getProducts(params.organizationId);
|
||||
const products = await getUserProducts(session.user.id, params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
|
||||
import { getPreviewSurvey } from "@formbricks/lib/styling/constants";
|
||||
import {
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
FormProvider,
|
||||
} from "@formbricks/ui/components/Form";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
import { MultiSelect } from "@formbricks/ui/components/MultiSelect";
|
||||
import { SurveyInline } from "@formbricks/ui/components/Survey";
|
||||
|
||||
interface ProductSettingsProps {
|
||||
@@ -37,6 +41,8 @@ interface ProductSettingsProps {
|
||||
channel: TProductConfigChannel;
|
||||
industry: TProductConfigIndustry;
|
||||
defaultBrandColor: string;
|
||||
organizationTeams: TOrganizationTeam[];
|
||||
canDoRoleManagement: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
@@ -46,8 +52,12 @@ export const ProductSettings = ({
|
||||
channel,
|
||||
industry,
|
||||
defaultBrandColor,
|
||||
organizationTeams,
|
||||
canDoRoleManagement = false,
|
||||
locale,
|
||||
}: ProductSettingsProps) => {
|
||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const addProduct = async (data: TProductUpdateInput) => {
|
||||
@@ -57,6 +67,7 @@ export const ProductSettings = ({
|
||||
data: {
|
||||
...data,
|
||||
config: { channel, industry },
|
||||
teamIds: data.teamIds,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,6 +103,7 @@ export const ProductSettings = ({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||
teamIds: [],
|
||||
},
|
||||
resolver: zodResolver(ZProductUpdateInput),
|
||||
});
|
||||
@@ -99,6 +111,11 @@ export const ProductSettings = ({
|
||||
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
const organizationTeamsOptions = organizationTeams.map((team) => ({
|
||||
label: team.name,
|
||||
value: team.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -155,8 +172,41 @@ export const ProductSettings = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{canDoRoleManagement && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="teamIds"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<FormLabel>Teams</FormLabel>
|
||||
<FormDescription>Who all can access this product?</FormDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setCreateTeamModalOpen(true)}>
|
||||
{t("organizations.products.new.settings.create_new_team")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div>
|
||||
<MultiSelect
|
||||
value={field.value}
|
||||
onChange={(teamIds) => field.onChange(teamIds)}
|
||||
options={organizationTeamsOptions}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
<Button loading={isSubmitting} type="submit" id="form-next-button">
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -175,7 +225,7 @@ export const ProductSettings = ({
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
<div className="h-3/4 w-3/4">
|
||||
<div className="z-0 h-3/4 w-3/4">
|
||||
<SurveyInline
|
||||
survey={getPreviewSurvey(locale)}
|
||||
styling={{ brandColor: { light: brandColor } }}
|
||||
@@ -186,6 +236,14 @@ export const ProductSettings = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CreateTeamModal
|
||||
open={createTeamModalOpen}
|
||||
setOpen={setCreateTeamModalOpen}
|
||||
organizationId={organizationId}
|
||||
onCreate={(teamId) => {
|
||||
form.setValue("teamIds", [...(form.getValues("teamIds") || []), teamId]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
|
||||
import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getRoleManagementPermission } from "@formbricks/ee/lib/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { DEFAULT_BRAND_COLOR, DEFAULT_LOCALE } from "@formbricks/lib/constants";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getUserProducts } from "@formbricks/lib/product/service";
|
||||
import { getUserLocale } from "@formbricks/lib/user/service";
|
||||
import { TProductConfigChannel, TProductConfigIndustry, TProductMode } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
@@ -25,12 +29,31 @@ interface ProductSettingsPageProps {
|
||||
const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
const t = await getTranslations();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const channel = searchParams.channel || null;
|
||||
const industry = searchParams.industry || null;
|
||||
const mode = searchParams.mode || "surveys";
|
||||
const locale = session?.user.id ? await getUserLocale(session.user.id) : undefined;
|
||||
const customHeadline = getCustomHeadline(channel);
|
||||
const products = await getProducts(params.organizationId);
|
||||
const products = await getUserProducts(session.user.id, params.organizationId);
|
||||
|
||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
@@ -51,6 +74,8 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
channel={channel}
|
||||
industry={industry}
|
||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||
organizationTeams={organizationTeams}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
/>
|
||||
{products.length >= 1 && (
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { sendInviteMemberEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { sendInviteMemberEmail } from "@formbricks/email";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
import { inviteUser } from "@formbricks/lib/invite/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { ZMembershipRole } from "@formbricks/types/memberships";
|
||||
import { ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
const ZInviteOrganizationMemberAction = z.object({
|
||||
organizationId: ZId,
|
||||
email: z.string(),
|
||||
role: ZMembershipRole,
|
||||
role: ZOrganizationRole,
|
||||
inviteMessage: z.string(),
|
||||
});
|
||||
|
||||
@@ -24,10 +24,15 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
|
||||
throw new AuthenticationError("Invite disabled");
|
||||
}
|
||||
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
rules: ["membership", "create"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const invite = await inviteUser({
|
||||
|
||||
8
apps/web/app/(app)/(onboarding)/types/onboarding.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOrganizationTeam = z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export type TOrganizationTeam = z.infer<typeof ZOrganizationTeam>;
|
||||
@@ -1,16 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
|
||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromProductId,
|
||||
getOrganizationIdFromSegmentId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
} from "@formbricks/lib/organization/utils";
|
||||
getProductIdFromEnvironmentId,
|
||||
getProductIdFromSegmentId,
|
||||
getProductIdFromSurveyId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { getSegment, getSurvey } from "@/lib/utils/services";
|
||||
import { z } from "zod";
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@formbricks/lib/constants";
|
||||
import { getProduct } from "@formbricks/lib/product/service";
|
||||
import {
|
||||
cloneSegment,
|
||||
@@ -28,11 +32,22 @@ import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||
export const updateSurveyAction = authenticatedActionClient
|
||||
.schema(ZSurvey)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.id),
|
||||
rules: ["survey", "update"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
productId: await getProductIdFromSurveyId(parsedInput.id),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateSurvey(parsedInput);
|
||||
});
|
||||
|
||||
@@ -43,10 +58,20 @@ const ZRefetchProductAction = z.object({
|
||||
export const refetchProductAction = authenticatedActionClient
|
||||
.schema(ZRefetchProductAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromProductId(parsedInput.productId),
|
||||
rules: ["product", "read"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: parsedInput.productId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getProduct(parsedInput.productId);
|
||||
@@ -64,10 +89,30 @@ const ZCreateBasicSegmentAction = z.object({
|
||||
export const createBasicSegmentAction = authenticatedActionClient
|
||||
.schema(ZCreateBasicSegmentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
const surveyEnvironment = await getSurvey(parsedInput.surveyId);
|
||||
|
||||
if (!surveyEnvironment) {
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
|
||||
if (surveyEnvironment.environmentId !== parsedInput.environmentId) {
|
||||
throw new Error("Survey and segment are not in the same environment");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["segment", "create"],
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(surveyEnvironment.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parsedFilters = ZSegmentFilters.safeParse(parsedInput.filters);
|
||||
@@ -99,10 +144,22 @@ const ZUpdateBasicSegmentAction = z.object({
|
||||
export const updateBasicSegmentAction = authenticatedActionClient
|
||||
.schema(ZUpdateBasicSegmentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
|
||||
rules: ["segment", "update"],
|
||||
access: [
|
||||
{
|
||||
schema: ZSegmentUpdateInput,
|
||||
data: parsedInput.data,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromSegmentId(parsedInput.segmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { filters } = parsedInput.data;
|
||||
@@ -127,16 +184,36 @@ const ZLoadNewBasicSegmentAction = z.object({
|
||||
export const loadNewBasicSegmentAction = authenticatedActionClient
|
||||
.schema(ZLoadNewBasicSegmentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSegmentId(parsedInput.surveyId),
|
||||
rules: ["segment", "read"],
|
||||
});
|
||||
const surveyEnvironment = await getSurvey(parsedInput.surveyId);
|
||||
const segmentEnvironment = await getSegment(parsedInput.segmentId);
|
||||
|
||||
await checkAuthorization({
|
||||
if (!surveyEnvironment || !segmentEnvironment) {
|
||||
if (!surveyEnvironment) {
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
if (!segmentEnvironment) {
|
||||
throw new Error("Segment not found");
|
||||
}
|
||||
}
|
||||
|
||||
if (surveyEnvironment.environmentId !== segmentEnvironment.environmentId) {
|
||||
throw new Error("Segment and survey are not in the same environment");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
rules: ["survey", "update"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await loadNewSegmentInSurvey(parsedInput.surveyId, parsedInput.segmentId);
|
||||
@@ -150,16 +227,36 @@ const ZCloneBasicSegmentAction = z.object({
|
||||
export const cloneBasicSegmentAction = authenticatedActionClient
|
||||
.schema(ZCloneBasicSegmentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
|
||||
rules: ["segment", "create"],
|
||||
});
|
||||
const surveyEnvironment = await getSurvey(parsedInput.surveyId);
|
||||
const segmentEnvironment = await getSegment(parsedInput.segmentId);
|
||||
|
||||
await checkAuthorization({
|
||||
if (!surveyEnvironment || !segmentEnvironment) {
|
||||
if (!surveyEnvironment) {
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
if (!segmentEnvironment) {
|
||||
throw new Error("Segment not found");
|
||||
}
|
||||
}
|
||||
|
||||
if (surveyEnvironment.environmentId !== segmentEnvironment.environmentId) {
|
||||
throw new Error("Segment and survey are not in the same environment");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
rules: ["survey", "read"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await cloneSegment(parsedInput.segmentId, parsedInput.surveyId);
|
||||
@@ -172,10 +269,20 @@ const ZResetBasicSegmentFiltersAction = z.object({
|
||||
export const resetBasicSegmentFiltersAction = authenticatedActionClient
|
||||
.schema(ZResetBasicSegmentFiltersAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
rules: ["segment", "update"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await resetSegmentInSurvey(parsedInput.surveyId);
|
||||
@@ -267,10 +374,20 @@ const ZCreateActionClassAction = z.object({
|
||||
export const createActionClassAction = authenticatedActionClient
|
||||
.schema(ZCreateActionClassAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.action.environmentId),
|
||||
rules: ["actionClass", "create"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.action.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await createActionClass(parsedInput.action.environmentId, parsedInput.action);
|
||||
|
||||
@@ -13,7 +13,7 @@ interface AddActionModalProps {
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
|
||||
isViewer: boolean;
|
||||
isReadOnly: boolean;
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const AddActionModal = ({
|
||||
setActionClasses,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
isViewer,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
}: AddActionModalProps) => {
|
||||
const t = useTranslations();
|
||||
@@ -48,7 +48,7 @@ export const AddActionModal = ({
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
setOpen={setOpen}
|
||||
isViewer={isViewer}
|
||||
isReadOnly={isReadOnly}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,7 +10,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
|
||||
|
||||
interface AddressQuestionFormProps {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
|
||||
const options = [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -9,7 +10,6 @@ import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionTo
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
|
||||
interface CalQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
|
||||
interface ConsentQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,7 +10,6 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { QuestionToggleTable } from "@formbricks/ui/components/QuestionToggleTable";
|
||||
|
||||
interface ContactInfoQuestionFormProps {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { createActionClassAction } from "../actions";
|
||||
interface CreateNewActionTabProps {
|
||||
actionClasses: TActionClass[];
|
||||
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
|
||||
isViewer: boolean;
|
||||
isReadOnly: boolean;
|
||||
setLocalSurvey?: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
environmentId: string;
|
||||
@@ -34,7 +34,7 @@ export const CreateNewActionTab = ({
|
||||
actionClasses,
|
||||
setActionClasses,
|
||||
setOpen,
|
||||
isViewer,
|
||||
isReadOnly,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
}: CreateNewActionTabProps) => {
|
||||
@@ -87,7 +87,7 @@ export const CreateNewActionTab = ({
|
||||
const submitHandler = async (data: TActionClassInput) => {
|
||||
const { type } = data;
|
||||
try {
|
||||
if (isViewer) {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
|
||||
@@ -247,9 +247,9 @@ export const CreateNewActionTab = ({
|
||||
<hr className="border-slate-200" />
|
||||
|
||||
{watch("type") === "code" ? (
|
||||
<CodeActionForm form={form} isEdit={false} />
|
||||
<CodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||
) : (
|
||||
<NoCodeActionForm form={form} />
|
||||
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end pt-6">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -8,7 +9,6 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
|
||||
interface IDateQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { Hand } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { FileInput } from "@formbricks/ui/components/FileInput";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/components/Switch";
|
||||
|
||||
interface EditWelcomeCardProps {
|
||||
|
||||
@@ -142,6 +142,30 @@ export const EditorCardMenu = ({
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<ArrowUpIcon
|
||||
className={cn(
|
||||
"h-4 cursor-pointer text-slate-500",
|
||||
cardIdx === 0 ? "cursor-not-allowed opacity-50" : "hover:text-slate-600"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (cardIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ArrowDownIcon
|
||||
className={cn(
|
||||
"h-4 cursor-pointer text-slate-500",
|
||||
lastCard ? "cursor-not-allowed opacity-50" : "hover:text-slate-600"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!lastCard) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CopyIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
@@ -8,7 +9,6 @@ import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/components/Switch";
|
||||
|
||||
interface EndScreenFormProps {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useGetBillingInfo } from "@/modules/utils/hooks/useGetBillingInfo";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon, XCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -8,7 +10,6 @@ import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { createI18nString } from "@formbricks/lib/i18n/utils";
|
||||
import { useGetBillingInfo } from "@formbricks/lib/organization/hooks/useGetBillingInfo";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
@@ -17,7 +18,6 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Input } from "@formbricks/ui/components/Input";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
|
||||
interface FileUploadFormProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,7 +10,6 @@ import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/s
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { findOptionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect";
|
||||
import { QuestionOptionChoice } from "./QuestionOptionChoice";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,7 +10,6 @@ import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
|
||||
interface NPSQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -14,7 +15,6 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/components/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
|
||||
const questionTypes = [
|
||||
{ value: "text", label: "common.text", icon: <MessageSquareTextIcon className="h-4 w-4" /> },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
@@ -10,7 +11,6 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { FileInput } from "@formbricks/ui/components/FileInput";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/components/Switch";
|
||||
|
||||
interface PictureSelectionFormProps {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ContactInfoQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/ContactInfoQuestionForm";
|
||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/components/Switch";
|
||||
import { AddressQuestionForm } from "./AddressQuestionForm";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
TSurveyRankingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface ChoiceProps {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
|
||||
import { SurveyVariablesCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyVariablesCard";
|
||||
import { findQuestionUsedInLogic } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/utils";
|
||||
import { MultiLanguageCard } from "@/modules/ee/multi-language-surveys/components/multi-language-card";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -17,7 +18,6 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { SetStateAction, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { isConditionGroup } from "@formbricks/lib/surveyLogic/utils";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
@@ -13,7 +14,6 @@ import { TI18nString, TSurvey, TSurveyRankingQuestion } from "@formbricks/types/
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { ShuffleOptionSelect } from "@formbricks/ui/components/ShuffleOptionSelect";
|
||||
import { QuestionOptionChoice } from "./QuestionOptionChoice";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -8,7 +9,6 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Label } from "@formbricks/ui/components/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/components/QuestionFormInput";
|
||||
import { Dropdown } from "./RatingTypeDropdown";
|
||||
|
||||
interface RatingQuestionFormProps {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AdvancedTargetingCard } from "@formbricks/ee/advanced-targeting/components/advanced-targeting-card";
|
||||
import { AdvancedTargetingCard } from "@/modules/ee/advanced-targeting/components/advanced-targeting-card";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { HowToSendCard } from "./HowToSendCard";
|
||||
@@ -20,10 +21,11 @@ interface SettingsViewProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
segments: TSegment[];
|
||||
responseCount: number;
|
||||
membershipRole?: TMembershipRole;
|
||||
membershipRole?: TOrganizationRole;
|
||||
isUserTargetingAllowed?: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
locale: string;
|
||||
productPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const SettingsView = ({
|
||||
@@ -38,6 +40,7 @@ export const SettingsView = ({
|
||||
isUserTargetingAllowed = false,
|
||||
isFormbricksCloud,
|
||||
locale,
|
||||
productPermission,
|
||||
}: SettingsViewProps) => {
|
||||
const isAppSurvey = localSurvey.type === "app";
|
||||
|
||||
@@ -83,6 +86,7 @@ export const SettingsView = ({
|
||||
environmentId={environment.id}
|
||||
propActionClasses={actionClasses}
|
||||
membershipRole={membershipRole}
|
||||
productPermission={productPermission}
|
||||
/>
|
||||
|
||||
<ResponseOptionsCard
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { extractLanguageCodes, getEnabledLanguages } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
@@ -7,7 +8,7 @@ import { useDocumentVisibility } from "@formbricks/lib/useDocumentVisibility";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -30,7 +31,7 @@ interface SurveyEditorProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
segments: TSegment[];
|
||||
responseCount: number;
|
||||
membershipRole?: TMembershipRole;
|
||||
membershipRole?: TOrganizationRole;
|
||||
colors: string[];
|
||||
isUserTargetingAllowed?: boolean;
|
||||
isMultiLanguageAllowed?: boolean;
|
||||
@@ -39,6 +40,7 @@ interface SurveyEditorProps {
|
||||
plan: TOrganizationBillingPlan;
|
||||
isCxMode: boolean;
|
||||
locale: TUserLocale;
|
||||
productPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -58,6 +60,7 @@ export const SurveyEditor = ({
|
||||
plan,
|
||||
isCxMode = false,
|
||||
locale,
|
||||
productPermission,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -209,6 +212,7 @@ export const SurveyEditor = ({
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
locale={locale}
|
||||
productPermission={productPermission}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createSegmentAction } from "@/modules/ee/advanced-targeting/lib/actions";
|
||||
import { isEqual } from "lodash";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon, SettingsIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { createSegmentAction } from "@formbricks/ee/advanced-targeting/lib/actions";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { AlertCircle, CheckIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon } from "lucide-react";
|
||||
@@ -8,7 +9,6 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { isAdvancedSegment } from "@formbricks/lib/segment/utils";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import {
|
||||
@@ -14,7 +16,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/components/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
@@ -26,7 +28,8 @@ interface WhenToSendCardProps {
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
environmentId: string;
|
||||
propActionClasses: TActionClass[];
|
||||
membershipRole?: TMembershipRole;
|
||||
membershipRole?: TOrganizationRole;
|
||||
productPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const WhenToSendCard = ({
|
||||
@@ -35,6 +38,7 @@ export const WhenToSendCard = ({
|
||||
setLocalSurvey,
|
||||
propActionClasses,
|
||||
membershipRole,
|
||||
productPermission,
|
||||
}: WhenToSendCardProps) => {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(localSurvey.type === "app" ? true : false);
|
||||
@@ -42,7 +46,10 @@ export const WhenToSendCard = ({
|
||||
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);
|
||||
const [randomizerToggle, setRandomizerToggle] = useState(localSurvey.displayPercentage ? true : false);
|
||||
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
const autoClose = localSurvey.autoClose !== null;
|
||||
const delay = localSurvey.delay !== 0;
|
||||
@@ -367,7 +374,7 @@ export const WhenToSendCard = ({
|
||||
setOpen={setAddActionModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
isViewer={isViewer}
|
||||
isReadOnly={isReadOnly}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getAdvancedTargetingPermission, getMultiLanguagePermission } from "@formbricks/ee/lib/service";
|
||||
@@ -60,10 +62,20 @@ const Page = async ({ params, searchParams }) => {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, product.id);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isSurveyCreationDeletionDisabled = isMember && hasReadAccess;
|
||||
const locale = session.user.id ? await getUserLocale(session.user.id) : undefined;
|
||||
|
||||
const isUserTargetingAllowed = await getAdvancedTargetingPermission(organization);
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
|
||||
|
||||
@@ -89,6 +101,7 @@ const Page = async ({ params, searchParams }) => {
|
||||
attributeClasses={attributeClasses}
|
||||
responseCount={responseCount}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
productPermission={productPermission}
|
||||
colors={SURVEY_BG_COLORS}
|
||||
segments={segments}
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { llmModel } from "@formbricks/lib/aiModels";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { createSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getIsAIEnabled } from "@formbricks/lib/utils/ai";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
@@ -23,10 +23,20 @@ export const createAISurveyAction = authenticatedActionClient
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
rules: ["survey", "create"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
@@ -35,7 +45,7 @@ export const createAISurveyAction = authenticatedActionClient
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled(organization.billing.plan);
|
||||
const isAIEnabled = await getIsAIEnabled(organization);
|
||||
|
||||
if (!isAIEnabled) {
|
||||
throw new Error("AI is not enabled for this organization");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { createAISurveyAction } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import {
|
||||
Card,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { FormbricksAICard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/FormbricksAICard";
|
||||
import { MenuBar } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/MenuBar";
|
||||
import { TemplateList } from "@/modules/surveys/components/TemplateList";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { getCustomSurveyTemplate } from "@formbricks/lib/templates";
|
||||
@@ -12,7 +13,6 @@ import { TUser } from "@formbricks/types/user";
|
||||
import { PreviewSurvey } from "@formbricks/ui/components/PreviewSurvey";
|
||||
import { SearchBar } from "@formbricks/ui/components/SearchBar";
|
||||
import { Separator } from "@formbricks/ui/components/Separator";
|
||||
import { TemplateList } from "@formbricks/ui/components/TemplateList";
|
||||
import { getMinimalSurvey } from "../../lib/minimalSurvey";
|
||||
|
||||
type TemplateContainerWithPreviewProps = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -52,9 +54,13 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
|
||||
session?.user.id,
|
||||
product.organizationId
|
||||
);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isViewer) {
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, product.id);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
if (isReadOnly) {
|
||||
return redirect(`/environments/${environment.id}/surveys`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getSegmentsByAttributeClassName } from "@formbricks/lib/segment/service";
|
||||
import { ZAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -16,10 +16,15 @@ const ZGetSegmentsByAttributeClassAction = z.object({
|
||||
export const getSegmentsByAttributeClassAction = authenticatedActionClient
|
||||
.schema(ZGetSegmentsByAttributeClassAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
access: [
|
||||
{
|
||||
type: "productTeam",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const segments = await getSegmentsByAttributeClassName(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
|
||||
@@ -11,9 +11,14 @@ import { AttributeClassDataRow } from "./AttributeRowData";
|
||||
interface AttributeClassesTableProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const AttributeClassesTable = ({ attributeClasses, locale }: AttributeClassesTableProps) => {
|
||||
export const AttributeClassesTable = ({
|
||||
attributeClasses,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: AttributeClassesTableProps) => {
|
||||
const [isAttributeDetailModalOpen, setAttributeDetailModalOpen] = useState(false);
|
||||
const [activeAttributeClass, setActiveAttributeClass] = useState<TAttributeClass | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
@@ -70,6 +75,7 @@ export const AttributeClassesTable = ({ attributeClasses, locale }: AttributeCla
|
||||
open={isAttributeDetailModalOpen}
|
||||
setOpen={setAttributeDetailModalOpen}
|
||||
attributeClass={activeAttributeClass}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,15 @@ interface AttributeDetailModalProps {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
attributeClass: TAttributeClass;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const AttributeDetailModal = ({ open, setOpen, attributeClass }: AttributeDetailModalProps) => {
|
||||
export const AttributeDetailModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
attributeClass,
|
||||
isReadOnly,
|
||||
}: AttributeDetailModalProps) => {
|
||||
const t = useTranslations();
|
||||
const tabs = [
|
||||
{
|
||||
@@ -22,7 +28,9 @@ export const AttributeDetailModal = ({ open, setOpen, attributeClass }: Attribut
|
||||
},
|
||||
{
|
||||
title: t("common.settings"),
|
||||
children: <AttributeSettingsTab attributeClass={attributeClass} setOpen={setOpen} />,
|
||||
children: (
|
||||
<AttributeSettingsTab attributeClass={attributeClass} setOpen={setOpen} isReadOnly={isReadOnly} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -14,9 +14,14 @@ import { Label } from "@formbricks/ui/components/Label";
|
||||
interface AttributeSettingsTabProps {
|
||||
attributeClass: AttributeClass;
|
||||
setOpen: (v: boolean) => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const AttributeSettingsTab = async ({ attributeClass, setOpen }: AttributeSettingsTabProps) => {
|
||||
export const AttributeSettingsTab = async ({
|
||||
attributeClass,
|
||||
setOpen,
|
||||
isReadOnly,
|
||||
}: AttributeSettingsTabProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { register, handleSubmit } = useForm({
|
||||
@@ -58,7 +63,7 @@ export const AttributeSettingsTab = async ({ attributeClass, setOpen }: Attribut
|
||||
type="text"
|
||||
placeholder={t("environments.attributes.ex_user_property")}
|
||||
{...register("description", {
|
||||
disabled: attributeClass.type === "automatic" ? true : false,
|
||||
disabled: attributeClass.type === "automatic" || isReadOnly ? true : false,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
@@ -85,24 +90,22 @@ export const AttributeSettingsTab = async ({ attributeClass, setOpen }: Attribut
|
||||
{t("common.read_docs")}
|
||||
</Button>
|
||||
{attributeClass.type !== "automatic" && (
|
||||
<Button className="ml-3" variant="secondary" onClick={handleArchiveToggle}>
|
||||
<Button
|
||||
className="ml-3"
|
||||
variant="secondary"
|
||||
onClick={handleArchiveToggle}
|
||||
StartIcon={attributeClass.archived ? ArchiveIcon : ArchiveXIcon}
|
||||
startIconClassName="h-4 w-4"
|
||||
disabled={isReadOnly}>
|
||||
{attributeClass.archived ? (
|
||||
<>
|
||||
{" "}
|
||||
<ArchiveXIcon className="mr-2 h-4 text-slate-600" />
|
||||
<span>{t("common.unarchive")}</span>
|
||||
</>
|
||||
<span>{t("common.unarchive")}</span>
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
<ArchiveIcon className="mr-2 h-4 text-slate-600" />
|
||||
<span>{t("common.archive")}</span>
|
||||
</>
|
||||
<span>{t("common.archive")}</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{attributeClass.type !== "automatic" && (
|
||||
{!isReadOnly && attributeClass.type !== "automatic" && (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" loading={isAttributeBeingSubmitted}>
|
||||
{t("common.save_changes")}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
@@ -23,6 +30,28 @@ const Page = async ({ params }) => {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
const [organization, session] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, product.id);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
const HowToAddAttributesButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -39,7 +68,7 @@ const Page = async ({ params }) => {
|
||||
<PageHeader pageTitle={t("common.people")} cta={HowToAddAttributesButton}>
|
||||
<PersonSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<AttributeClassesTable attributeClasses={attributeClasses} locale={locale} />
|
||||
<AttributeClassesTable attributeClasses={attributeClasses} locale={locale} isReadOnly={isReadOnly} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
|
||||
const ConfigLayout = async ({ children, params }) => {
|
||||
const t = await getTranslations();
|
||||
const [organization, session] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError(t("common.not_authorized"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
if (!product) {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ConfigLayout;
|
||||
@@ -1,21 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { deletePersonAction } from "@formbricks/ui/components/DataTable/actions";
|
||||
import { DeleteDialog } from "@formbricks/ui/components/DeleteDialog";
|
||||
|
||||
interface DeletePersonButtonProps {
|
||||
environmentId: string;
|
||||
personId: string;
|
||||
isViewer: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const DeletePersonButton = ({ environmentId, personId, isViewer }: DeletePersonButtonProps) => {
|
||||
export const DeletePersonButton = ({ environmentId, personId, isReadOnly }: DeletePersonButtonProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -42,7 +42,7 @@ export const DeletePersonButton = ({ environmentId, personId, isViewer }: Delete
|
||||
}
|
||||
};
|
||||
|
||||
if (isViewer) {
|
||||
if (isReadOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ResponseTimeline } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponseTimeline";
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
@@ -28,6 +30,7 @@ export const ResponseSection = async ({
|
||||
const surveyIds = responses?.map((response) => response.surveyId) || [];
|
||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : ((await getSurveys(environment.id)) ?? []);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
const t = await getTranslations();
|
||||
if (!session) {
|
||||
throw new Error(t("common.no_session_found"));
|
||||
@@ -42,6 +45,15 @@ export const ResponseSection = async ({
|
||||
if (!responses) {
|
||||
throw new Error(t("environments.people.no_responses_found"));
|
||||
}
|
||||
|
||||
const product = await getProductByEnvironmentId(environment.id);
|
||||
|
||||
if (!product) {
|
||||
throw new Error(t("common.no_product_found"));
|
||||
}
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, product.id);
|
||||
|
||||
const locale = findMatchingLocale();
|
||||
|
||||
return (
|
||||
@@ -53,6 +65,7 @@ export const ResponseSection = async ({
|
||||
environmentTags={environmentTags}
|
||||
attributeClasses={attributeClasses}
|
||||
locale={locale}
|
||||
productPermission={productPermission}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ResponseFeed } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponsesFeed";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { ArrowDownUpIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -19,6 +20,7 @@ interface ResponseTimelineProps {
|
||||
environmentTags: TTag[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
locale: TUserLocale;
|
||||
productPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const ResponseTimeline = ({
|
||||
@@ -29,6 +31,7 @@ export const ResponseTimeline = ({
|
||||
environmentTags,
|
||||
attributeClasses,
|
||||
locale,
|
||||
productPermission,
|
||||
}: ResponseTimelineProps) => {
|
||||
const t = useTranslations();
|
||||
const [sortedResponses, setSortedResponses] = useState(responses);
|
||||
@@ -61,6 +64,7 @@ export const ResponseTimeline = ({
|
||||
environmentTags={environmentTags}
|
||||
attributeClasses={attributeClasses}
|
||||
locale={locale}
|
||||
productPermission={productPermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -11,7 +14,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/components/EmptySpaceFiller";
|
||||
import { SingleResponseCard } from "@formbricks/ui/components/SingleResponseCard";
|
||||
|
||||
interface ResponseTimelineProps {
|
||||
surveys: TSurvey[];
|
||||
@@ -21,6 +23,7 @@ interface ResponseTimelineProps {
|
||||
environmentTags: TTag[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
locale: TUserLocale;
|
||||
productPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const ResponseFeed = ({
|
||||
@@ -31,6 +34,7 @@ export const ResponseFeed = ({
|
||||
environmentTags,
|
||||
attributeClasses,
|
||||
locale,
|
||||
productPermission,
|
||||
}: ResponseTimelineProps) => {
|
||||
const [fetchedResponses, setFetchedResponses] = useState(responses);
|
||||
|
||||
@@ -65,6 +69,7 @@ export const ResponseFeed = ({
|
||||
updateResponse={updateResponse}
|
||||
attributeClasses={attributeClasses}
|
||||
locale={locale}
|
||||
productPermission={productPermission}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -82,6 +87,7 @@ const ResponseSurveyCard = ({
|
||||
updateResponse,
|
||||
attributeClasses,
|
||||
locale,
|
||||
productPermission,
|
||||
}: {
|
||||
response: TResponse;
|
||||
surveys: TSurvey[];
|
||||
@@ -92,13 +98,18 @@ const ResponseSurveyCard = ({
|
||||
updateResponse: (responseId: string, response: TResponse) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
locale: TUserLocale;
|
||||
productPermission: TTeamPermission | null;
|
||||
}) => {
|
||||
const survey = surveys.find((survey) => {
|
||||
return survey.id === response.surveyId;
|
||||
});
|
||||
|
||||
const { membershipRole } = useMembershipRole(survey?.environmentId || "");
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
return (
|
||||
<div key={response.id}>
|
||||
@@ -112,7 +123,7 @@ const ResponseSurveyCard = ({
|
||||
environment={environment}
|
||||
deleteResponses={deleteResponses}
|
||||
updateResponse={updateResponse}
|
||||
isViewer={isViewer}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AttributesSection } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/AttributesSection";
|
||||
import { DeletePersonButton } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton";
|
||||
import { ResponseSection } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ResponseSection";
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
@@ -52,11 +54,16 @@ const Page = async ({ params }) => {
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, product.id);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
const getDeletePersonButton = () => {
|
||||
return (
|
||||
<DeletePersonButton environmentId={environment.id} personId={params.personId} isViewer={isViewer} />
|
||||
<DeletePersonButton environmentId={environment.id} personId={params.personId} isReadOnly={isReadOnly} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromPersonId,
|
||||
getProductIdFromEnvironmentId,
|
||||
getProductIdFromPersonId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getAttributes } from "@formbricks/lib/attribute/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getPeople } from "@formbricks/lib/person/service";
|
||||
import { deletePerson, getPeople } from "@formbricks/lib/person/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetPersonsAction = z.object({
|
||||
@@ -17,28 +21,47 @@ const ZGetPersonsAction = z.object({
|
||||
export const getPersonsAction = authenticatedActionClient
|
||||
.schema(ZGetPersonsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "read",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getPeople(parsedInput.environmentId, parsedInput.offset, parsedInput.searchValue);
|
||||
});
|
||||
|
||||
const ZGetPersonAttributesAction = z.object({
|
||||
environmentId: ZId,
|
||||
const ZPersonDeleteAction = z.object({
|
||||
personId: ZId,
|
||||
});
|
||||
|
||||
export const getPersonAttributesAction = authenticatedActionClient
|
||||
.schema(ZGetPersonAttributesAction)
|
||||
export const deletePersonAction = authenticatedActionClient
|
||||
.schema(ZPersonDeleteAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["environment", "read"],
|
||||
organizationId: await getOrganizationIdFromPersonId(parsedInput.personId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromPersonId(parsedInput.personId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getAttributes(parsedInput.personId);
|
||||
return await deletePerson(parsedInput.personId);
|
||||
});
|
||||
|
||||
@@ -12,9 +12,10 @@ import { TPersonWithAttributes } from "@formbricks/types/people";
|
||||
interface PersonDataViewProps {
|
||||
environment: TEnvironment;
|
||||
itemsPerPage: number;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const PersonDataView = ({ environment, itemsPerPage }: PersonDataViewProps) => {
|
||||
export const PersonDataView = ({ environment, itemsPerPage, isReadOnly }: PersonDataViewProps) => {
|
||||
const t = useTranslations();
|
||||
const [persons, setPersons] = useState<TPersonWithAttributes[]>([]);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState<boolean>(false);
|
||||
@@ -101,6 +102,7 @@ export const PersonDataView = ({ environment, itemsPerPage }: PersonDataViewProp
|
||||
environmentId={environment.id}
|
||||
searchValue={searchValue}
|
||||
setSearchValue={setSearchValue}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/actions";
|
||||
import { generatePersonTableColumns } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -40,6 +41,7 @@ interface PersonTableProps {
|
||||
environmentId: string;
|
||||
searchValue: string;
|
||||
setSearchValue: (value: string) => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const PersonTable = ({
|
||||
@@ -51,6 +53,7 @@ export const PersonTable = ({
|
||||
environmentId,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
isReadOnly,
|
||||
}: PersonTableProps) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
@@ -63,7 +66,7 @@ export const PersonTable = ({
|
||||
const [parent] = useAutoAnimate();
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generatePersonTableColumns(isExpanded ?? false, searchValue, t),
|
||||
() => generatePersonTableColumns(isExpanded ?? false, searchValue, t, isReadOnly),
|
||||
[isExpanded, searchValue]
|
||||
);
|
||||
|
||||
@@ -162,6 +165,10 @@ export const PersonTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const deletePerson = async (personId: string) => {
|
||||
await deletePersonAction({ personId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SearchBar
|
||||
@@ -181,12 +188,13 @@ export const PersonTable = ({
|
||||
table={table}
|
||||
deleteRows={deletePersons}
|
||||
type="person"
|
||||
deleteAction={deletePerson}
|
||||
/>
|
||||
<div className="w-full overflow-x-auto rounded-xl border border-slate-200">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader>
|
||||
<TableHeader className="pointer-events-auto">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
<TableRow key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
@@ -196,7 +204,7 @@ export const PersonTable = ({
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import { HighlightedText } from "@formbricks/ui/components/HighlightedText";
|
||||
export const generatePersonTableColumns = (
|
||||
isExpanded: boolean,
|
||||
searchValue: string,
|
||||
t: (key: string) => string
|
||||
t: (key: string) => string,
|
||||
isReadOnly: boolean
|
||||
): ColumnDef<TPersonTableData>[] => {
|
||||
const dateColumn: ColumnDef<TPersonTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -90,5 +91,7 @@ export const generatePersonTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn];
|
||||
const columns = [dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn];
|
||||
|
||||
return isReadOnly ? columns : [getSelectionColumn(), ...columns];
|
||||
};
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView";
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const t = await getTranslations();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(params.environmentId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
if (!product) {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, product.id);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
const HowToAddPeopleButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -31,7 +64,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<PageHeader pageTitle={t("common.people")} cta={HowToAddPeopleButton}>
|
||||
<PersonSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<PersonDataView environment={environment} itemsPerPage={ITEMS_PER_PAGE} />
|
||||
<PersonDataView environment={environment} itemsPerPage={ITEMS_PER_PAGE} isReadOnly={isReadOnly} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromSegmentId, getProductIdFromSegmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromSegmentId } from "@formbricks/lib/organization/utils";
|
||||
import { deleteSegment, updateSegment } from "@formbricks/lib/segment/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
@@ -15,10 +15,20 @@ const ZDeleteBasicSegmentAction = z.object({
|
||||
export const deleteBasicSegmentAction = authenticatedActionClient
|
||||
.schema(ZDeleteBasicSegmentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
|
||||
rules: ["segment", "delete"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromSegmentId(parsedInput.segmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await deleteSegment(parsedInput.segmentId);
|
||||
@@ -32,10 +42,20 @@ const ZUpdateBasicSegmentAction = z.object({
|
||||
export const updateBasicSegmentAction = authenticatedActionClient
|
||||
.schema(ZUpdateBasicSegmentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSegmentId(parsedInput.segmentId),
|
||||
rules: ["segment", "update"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromSegmentId(parsedInput.segmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { filters } = parsedInput.data;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createSegmentAction } from "@/modules/ee/advanced-targeting/lib/actions";
|
||||
import { FilterIcon, PlusIcon, UsersIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { createSegmentAction } from "@formbricks/ee/advanced-targeting/lib/actions";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TBaseFilter, TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
|
||||
@@ -23,6 +23,7 @@ type TBasicSegmentSettingsTabProps = {
|
||||
initialSegment: TSegmentWithSurveyNames;
|
||||
attributeClasses: TAttributeClass[];
|
||||
isFormbricksCloud: boolean;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const BasicSegmentSettings = ({
|
||||
@@ -31,6 +32,7 @@ export const BasicSegmentSettings = ({
|
||||
setOpen,
|
||||
attributeClasses,
|
||||
isFormbricksCloud,
|
||||
isReadOnly,
|
||||
}: TBasicSegmentSettingsTabProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
@@ -150,6 +152,7 @@ export const BasicSegmentSettings = ({
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
value={segment.title}
|
||||
disabled={isReadOnly}
|
||||
placeholder={t("environments.segments.ex_power_users")}
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
@@ -167,6 +170,7 @@ export const BasicSegmentSettings = ({
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
value={segment.description ?? ""}
|
||||
disabled={isReadOnly}
|
||||
placeholder={t("environments.segments.ex_fully_activated_recurring_users")}
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
@@ -197,10 +201,15 @@ export const BasicSegmentSettings = ({
|
||||
setSegment={setSegment}
|
||||
group={segment.filters}
|
||||
attributeClasses={attributeClasses}
|
||||
viewOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setAddFilterModalOpen(true)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setAddFilterModalOpen(true)}
|
||||
disabled={isReadOnly}>
|
||||
{t("common.add_filter")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -229,28 +238,30 @@ export const BasicSegmentSettings = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
loading={isDeletingSegment}
|
||||
onClick={() => {
|
||||
setIsDeleteSegmentModalOpen(true);
|
||||
}}
|
||||
EndIcon={Trash2}
|
||||
endIconClassName="p-0.5">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isUpdatingSegment}
|
||||
onClick={() => {
|
||||
handleUpdateSegment();
|
||||
}}
|
||||
disabled={isSaveDisabled}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
loading={isDeletingSegment}
|
||||
onClick={() => {
|
||||
setIsDeleteSegmentModalOpen(true);
|
||||
}}
|
||||
EndIcon={Trash2}
|
||||
endIconClassName="p-0.5">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isUpdatingSegment}
|
||||
onClick={() => {
|
||||
handleUpdateSegment();
|
||||
}}
|
||||
disabled={isSaveDisabled}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDeleteSegmentModalOpen && (
|
||||
<ConfirmDeleteSegmentModal
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SegmentSettings } from "@/modules/ee/advanced-targeting/components/segment-settings";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SegmentSettings } from "@formbricks/ee/advanced-targeting/components/segment-settings";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
|
||||
@@ -18,6 +18,7 @@ interface EditSegmentModalProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const EditSegmentModal = ({
|
||||
@@ -29,10 +30,11 @@ export const EditSegmentModal = ({
|
||||
segments,
|
||||
isAdvancedTargetingAllowed,
|
||||
isFormbricksCloud,
|
||||
isReadOnly,
|
||||
}: EditSegmentModalProps) => {
|
||||
const t = useTranslations();
|
||||
const SettingsTab = () => {
|
||||
if (isAdvancedTargetingAllowed) {
|
||||
if (isAdvancedTargetingAllowed || false) {
|
||||
return (
|
||||
<SegmentSettings
|
||||
attributeClasses={attributeClasses}
|
||||
@@ -40,6 +42,7 @@ export const EditSegmentModal = ({
|
||||
initialSegment={currentSegment}
|
||||
segments={segments}
|
||||
setOpen={setOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +54,7 @@ export const EditSegmentModal = ({
|
||||
initialSegment={currentSegment}
|
||||
setOpen={setOpen}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,12 +7,14 @@ type TSegmentTableProps = {
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTable = ({
|
||||
segments,
|
||||
attributeClasses,
|
||||
isAdvancedTargetingAllowed,
|
||||
isReadOnly,
|
||||
}: TSegmentTableProps) => {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
@@ -35,6 +37,7 @@ export const SegmentTable = ({
|
||||
segments={segments}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -13,6 +13,7 @@ type TSegmentTableDataRowProps = {
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTableDataRow = ({
|
||||
@@ -21,6 +22,7 @@ export const SegmentTableDataRow = ({
|
||||
segments,
|
||||
isAdvancedTargetingAllowed,
|
||||
isFormbricksCloud,
|
||||
isReadOnly,
|
||||
}: TSegmentTableDataRowProps) => {
|
||||
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
|
||||
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
|
||||
@@ -66,6 +68,7 @@ export const SegmentTableDataRow = ({
|
||||
segments={segments}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ type TSegmentTableDataRowProps = {
|
||||
segments: TSegment[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
isAdvancedTargetingAllowed: boolean;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTableDataRowContainer = async ({
|
||||
@@ -16,6 +17,7 @@ export const SegmentTableDataRowContainer = async ({
|
||||
segments,
|
||||
attributeClasses,
|
||||
isAdvancedTargetingAllowed,
|
||||
isReadOnly,
|
||||
}: TSegmentTableDataRowProps) => {
|
||||
const surveys = await getSurveysBySegmentId(currentSegment.id);
|
||||
|
||||
@@ -38,6 +40,7 @@ export const SegmentTableDataRowContainer = async ({
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
||||
import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal";
|
||||
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
||||
import { CreateSegmentModal } from "@/modules/ee/advanced-targeting/components/create-segment-modal";
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
||||
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSegments } from "@formbricks/lib/segment/service";
|
||||
import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const t = await getTranslations();
|
||||
const [environment, segments, attributeClasses, organization] = await Promise.all([
|
||||
const [environment, segments, attributeClasses, organization, product] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getSegments(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
@@ -35,6 +52,15 @@ const Page = async ({ params }) => {
|
||||
throw new Error(t("environments.segments.failed_to_fetch_segments"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, product.id);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
const filteredSegments = segments.filter((segment) => !segment.isPrivate);
|
||||
|
||||
const renderCreateSegmentButton = () =>
|
||||
@@ -54,13 +80,14 @@ const Page = async ({ params }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.people")} cta={renderCreateSegmentButton()}>
|
||||
<PageHeader pageTitle={t("common.people")} cta={!isReadOnly ? renderCreateSegmentButton() : undefined}>
|
||||
<PersonSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
<SegmentTable
|
||||
segments={filteredSegments}
|
||||
attributeClasses={attributeClasses}
|
||||
isAdvancedTargetingAllowed={isAdvancedTargetingAllowed}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { z } from "zod";
|
||||
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createOrganization } from "@formbricks/lib/organization/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
@@ -70,12 +70,17 @@ export const createProductAction = authenticatedActionClient
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
await checkAuthorization({
|
||||
schema: ZProductUpdateInput,
|
||||
data: parsedInput.data,
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
rules: ["product", "create"],
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProductUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const product = await createProduct(parsedInput.organizationId, parsedInput.data);
|
||||
|
||||