mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
feat: granular team roles (#3975)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com> Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -13,64 +13,91 @@ export const metadata = {
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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) | |
|
||||
|
||||
@@ -126,7 +126,7 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
|
||||
],
|
||||
},
|
||||
{ title: "Access Roles", href: "/global/access-roles" },
|
||||
{ title: "Organization and User Management", href: "/global/access-roles" },
|
||||
{ title: "Styling Theme", href: "/global/styling-theme" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
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
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +188,7 @@ 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" }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromActionClassId, getProductIdFromActionClassId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { getOrganizationIdFromActionClassId } from "@formbricks/lib/organization/utils";
|
||||
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -18,10 +18,20 @@ const ZDeleteActionClassAction = z.object({
|
||||
export const deleteActionClassAction = authenticatedActionClient
|
||||
.schema(ZDeleteActionClassAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
rules: ["actionClass", "delete"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await deleteActionClass(parsedInput.actionClassId);
|
||||
@@ -40,10 +50,20 @@ export const updateActionClassAction = authenticatedActionClient
|
||||
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
|
||||
}
|
||||
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
rules: ["actionClass", "update"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateActionClass(
|
||||
@@ -60,10 +80,20 @@ const ZGetActiveInactiveSurveysAction = z.object({
|
||||
export const getActiveInactiveSurveysAction = authenticatedActionClient
|
||||
.schema(ZGetActiveInactiveSurveysAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
rules: ["survey", "read"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "read",
|
||||
productId: await getProductIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const surveys = await getSurveysByActionClassId(parsedInput.actionClassId);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } 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 { TActionClass } from "@formbricks/types/action-classes";
|
||||
@@ -32,6 +32,7 @@ export const ActionActivityTab = ({ actionClass, environmentId }: ActivityTabPro
|
||||
const getActiveInactiveSurveysResponse = await getActiveInactiveSurveysAction({
|
||||
actionClassId: actionClass.id,
|
||||
});
|
||||
console.log(getActiveInactiveSurveysResponse, "randike");
|
||||
if (getActiveInactiveSurveysResponse?.data) {
|
||||
setActiveSurveys(getActiveInactiveSurveysResponse.data.activeSurveys);
|
||||
setInactiveSurveys(getActiveInactiveSurveysResponse.data.inactiveSurveys);
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { ActionDetailModal } from "./ActionDetailModal";
|
||||
|
||||
interface ActionClassesTableProps {
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ActionClassesTable = ({
|
||||
environmentId,
|
||||
actionClasses,
|
||||
children: [TableHeading, actionRows],
|
||||
isReadOnly,
|
||||
}: ActionClassesTableProps) => {
|
||||
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||
const { membershipRole, error } = useMembershipRole(environmentId);
|
||||
|
||||
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
||||
|
||||
@@ -28,9 +27,6 @@ export const ActionClassesTable = ({
|
||||
setActionDetailModalOpen(true);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
@@ -56,7 +52,7 @@ export const ActionClassesTable = ({
|
||||
setOpen={setActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
membershipRole={membershipRole}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { ModalWithTabs } from "@formbricks/ui/components/ModalWithTabs";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
@@ -12,7 +11,7 @@ interface ActionDetailModalProps {
|
||||
setOpen: (v: boolean) => void;
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
membershipRole?: TMembershipRole;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ActionDetailModal = ({
|
||||
@@ -21,7 +20,7 @@ export const ActionDetailModal = ({
|
||||
setOpen,
|
||||
actionClass,
|
||||
actionClasses,
|
||||
membershipRole,
|
||||
isReadOnly,
|
||||
}: ActionDetailModalProps) => {
|
||||
const t = useTranslations();
|
||||
const tabs = [
|
||||
@@ -36,7 +35,7 @@ export const ActionDetailModal = ({
|
||||
actionClass={actionClass}
|
||||
actionClasses={actionClasses}
|
||||
setOpen={setOpen}
|
||||
membershipRole={membershipRole}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -13,9 +13,7 @@ import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { DeleteDialog } from "@formbricks/ui/components/DeleteDialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@formbricks/ui/components/Form";
|
||||
@@ -27,14 +25,14 @@ interface ActionSettingsTabProps {
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
setOpen: (v: boolean) => void;
|
||||
membershipRole?: TMembershipRole;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ActionSettingsTab = ({
|
||||
actionClass,
|
||||
actionClasses,
|
||||
setOpen,
|
||||
membershipRole,
|
||||
isReadOnly,
|
||||
}: ActionSettingsTabProps) => {
|
||||
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
|
||||
const router = useRouter();
|
||||
@@ -42,7 +40,7 @@ export const ActionSettingsTab = ({
|
||||
const t = useTranslations();
|
||||
const [isUpdatingAction, setIsUpdatingAction] = useState(false);
|
||||
const [isDeletingAction, setIsDeletingAction] = useState(false);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const actionClassNames = useMemo(
|
||||
() =>
|
||||
actionClasses.filter((action) => action.id !== actionClass.id).map((actionClass) => actionClass.name),
|
||||
@@ -72,7 +70,7 @@ export const ActionSettingsTab = ({
|
||||
|
||||
const onSubmit = async (data: TActionClassInput) => {
|
||||
try {
|
||||
if (isViewer) {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
setIsUpdatingAction(true);
|
||||
@@ -141,6 +139,7 @@ export const ActionSettingsTab = ({
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
disabled={isReadOnly}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionNameSettingsInput">
|
||||
@@ -156,7 +155,7 @@ export const ActionSettingsTab = ({
|
||||
{...field}
|
||||
placeholder={t("environments.actions.eg_clicked_download")}
|
||||
isInvalid={!!error?.message}
|
||||
disabled={actionClass.type === "automatic" ? true : false}
|
||||
disabled={actionClass.type === "automatic" || isReadOnly ? true : false}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -165,43 +164,42 @@ export const ActionSettingsTab = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!isViewer && (
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
||||
{t("common.description")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionDescriptionSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||
value={field.value ?? ""}
|
||||
disabled={actionClass.type === "automatic" ? true : false}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
||||
{t("common.description")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionDescriptionSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||
value={field.value ?? ""}
|
||||
disabled={actionClass.type === "automatic" || isReadOnly ? true : false}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionClass.type === "code" ? (
|
||||
<>
|
||||
<CodeActionForm form={form} isEdit={true} />
|
||||
<CodeActionForm form={form} isReadOnly={true} />
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||
</p>
|
||||
</>
|
||||
) : actionClass.type === "noCode" ? (
|
||||
<NoCodeActionForm form={form} />
|
||||
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">
|
||||
{t(
|
||||
@@ -213,7 +211,7 @@ export const ActionSettingsTab = ({
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
{!isViewer && actionClass.type !== "automatic" && (
|
||||
{!isReadOnly && actionClass.type !== "automatic" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
@@ -230,7 +228,7 @@ export const ActionSettingsTab = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{actionClass.type !== "automatic" && (
|
||||
{!isReadOnly && actionClass.type !== "automatic" && (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" loading={isUpdatingAction}>
|
||||
{t("common.save_changes")}
|
||||
|
||||
@@ -4,32 +4,25 @@ import { CreateNewActionTab } from "@/app/(app)/(survey-editor)/environments/[en
|
||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { Modal } from "@formbricks/ui/components/Modal";
|
||||
|
||||
interface AddActionModalProps {
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const AddActionModal = ({ environmentId, actionClasses }: AddActionModalProps) => {
|
||||
export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: AddActionModalProps) => {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { membershipRole, isLoading, error } = useMembershipRole(environmentId);
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
const [newActionClasses, setNewActionClasses] = useState<TActionClass[]>(actionClasses);
|
||||
|
||||
if (error) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
const [newActionClasses, setNewActionClasses] = useState<TActionClass[]>(actionClasses);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" loading={isLoading} onClick={() => setOpen(true)} EndIcon={PlusIcon}>
|
||||
<Button size="sm" onClick={() => setOpen(true)} EndIcon={PlusIcon}>
|
||||
{t("common.add_action")}
|
||||
</Button>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
||||
@@ -56,7 +49,7 @@ export const AddActionModal = ({ environmentId, actionClasses }: AddActionModalP
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isViewer={isViewer}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,18 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act
|
||||
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
|
||||
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
|
||||
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/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 { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
@@ -15,25 +23,55 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const Page = async ({ params }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const t = await getTranslations();
|
||||
const [actionClasses, organization] = await Promise.all([
|
||||
const [actionClasses, organization, product] = await Promise.all([
|
||||
getActionClasses(params.environmentId),
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
const locale = await findMatchingLocale();
|
||||
const locale = findMatchingLocale();
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
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 { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, product.id);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
const renderAddActionButton = () => (
|
||||
<AddActionModal environmentId={params.environmentId} actionClasses={actionClasses} />
|
||||
<AddActionModal
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.actions")} cta={renderAddActionButton()} />
|
||||
<ActionClassesTable environmentId={params.environmentId} actionClasses={actionClasses}>
|
||||
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
||||
<ActionClassesTable
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}>
|
||||
<ActionTableHeading />
|
||||
{actionClasses.map((actionClass) => (
|
||||
<ActionClassDataRow key={actionClass.id} actionClass={actionClass} locale={locale} />
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getIsAIEnabled } from "@/app/lib/utils";
|
||||
import { getProductPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import type { Session } from "next-auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { getUserProducts } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { DevEnvironmentBanner } from "@formbricks/ui/components/DevEnvironmentBanner";
|
||||
import { LimitsReachedBanner } from "@formbricks/ui/components/LimitsReachedBanner";
|
||||
@@ -47,7 +49,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
}
|
||||
|
||||
const [products, environments] = await Promise.all([
|
||||
getProducts(organization.id),
|
||||
getUserProducts(user.id, organization.id),
|
||||
getEnvironments(environment.productId),
|
||||
]);
|
||||
|
||||
@@ -56,8 +58,19 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session.user.id, environment.productId);
|
||||
|
||||
if (!isOwnerOrManager && !productPermission) {
|
||||
throw new Error(t("common.product_permission_not_found"));
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
let peopleCount = 0;
|
||||
@@ -100,7 +113,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
products={products}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
membershipRole={membershipRole}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isAIEnabled={isAIEnabled}
|
||||
/>
|
||||
@@ -108,7 +121,8 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
<TopControlBar
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
membershipRole={membershipRole}
|
||||
productPermission={productPermission}
|
||||
/>
|
||||
<div className="mt-14">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BlendIcon,
|
||||
@@ -38,13 +39,12 @@ import { cn } from "@formbricks/lib/cn";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { ProfileAvatar } from "@formbricks/ui/components/Avatars";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { CreateOrganizationModal } from "@formbricks/ui/components/CreateOrganizationModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -68,7 +68,7 @@ interface NavigationProps {
|
||||
products: TProduct[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
isFormbricksCloud?: boolean;
|
||||
membershipRole?: TMembershipRole;
|
||||
membershipRole?: TOrganizationRole;
|
||||
isAIEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -94,9 +94,10 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
|
||||
const product = products.find((product) => product.id === environment.productId);
|
||||
const { isAdmin, isOwner, isViewer } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrAdmin = isAdmin || isOwner;
|
||||
const isPricingDisabled = !isOwner && !isAdmin;
|
||||
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
@@ -196,17 +197,15 @@ export const MainNavigation = ({
|
||||
href: `/environments/${environment.id}/integrations`,
|
||||
icon: BlocksIcon,
|
||||
isActive: pathname?.includes("/integrations"),
|
||||
isHidden: isViewer,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/product/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/product"),
|
||||
isHidden: isViewer,
|
||||
},
|
||||
],
|
||||
[environment.id, pathname, isViewer]
|
||||
[environment.id, pathname, isMember]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
@@ -258,8 +257,10 @@ export const MainNavigation = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isOwnerOrAdmin) loadReleases();
|
||||
}, [isOwnerOrAdmin]);
|
||||
if (isOwnerOrManager) loadReleases();
|
||||
}, [isOwnerOrManager]);
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -276,7 +277,7 @@ export const MainNavigation = ({
|
||||
<div className="flex items-center justify-between px-3 pb-4">
|
||||
{!isCollapsed && (
|
||||
<Link
|
||||
href={`/environments/${environment.id}/surveys/`}
|
||||
href={mainNavigationLink}
|
||||
className={cn(
|
||||
"flex items-center justify-center transition-opacity duration-100",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
@@ -301,28 +302,29 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
{!isBilling && (
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Switch */}
|
||||
<div>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed && isOwnerOrAdmin && latestVersion && !isFormbricksCloud && (
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
@@ -333,100 +335,104 @@ export const MainNavigation = ({
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="productDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-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",
|
||||
isCollapsed ? "pl-2" : "pl-4"
|
||||
)}>
|
||||
<div className="rounded-lg bg-slate-900 p-1.5 text-slate-50">
|
||||
{product.config.channel === "website" ? (
|
||||
<GlobeIcon strokeWidth={1.5} />
|
||||
) : product.config.channel === "app" ? (
|
||||
<GlobeLockIcon strokeWidth={1.5} />
|
||||
) : product.config.channel === "link" ? (
|
||||
<LinkIcon strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon strokeWidth={1.5} />
|
||||
|
||||
{/* Product Switch */}
|
||||
{!isBilling && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="productDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-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",
|
||||
isCollapsed ? "pl-2" : "pl-4"
|
||||
)}>
|
||||
<div className="rounded-lg bg-slate-900 p-1.5 text-slate-50">
|
||||
{product.config.channel === "website" ? (
|
||||
<GlobeIcon strokeWidth={1.5} />
|
||||
) : product.config.channel === "app" ? (
|
||||
<GlobeLockIcon strokeWidth={1.5} />
|
||||
) : product.config.channel === "link" ? (
|
||||
<LinkIcon strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div>
|
||||
<p
|
||||
title={product.name}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{product.name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-slate-500 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{product.config.channel === "link"
|
||||
? "Link & Email"
|
||||
: capitalizeFirstLetter(product.config.channel)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"h-5 w-5 text-slate-700 transition-opacity duration-200 hover:text-slate-500",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={-1}
|
||||
align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={product!.id}
|
||||
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
|
||||
{sortedProducts.map((product) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={product.id}
|
||||
className="cursor-pointer break-all"
|
||||
key={product.id}>
|
||||
<div>
|
||||
{product.config.channel === "website" ? (
|
||||
<GlobeIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : product.config.channel === "app" ? (
|
||||
<GlobeLockIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : product.config.channel === "link" ? (
|
||||
<LinkIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
<div className="">{product?.name}</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
{isOwnerOrManager && (
|
||||
<>
|
||||
<div>
|
||||
<p
|
||||
title={product.name}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{product.name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-slate-500 transition-opacity duration-200",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{product.config.channel && product.config.channel === "link"
|
||||
? t("common.link_and_email")
|
||||
: product.config.channel
|
||||
? t(`common.${product.config.channel}`)
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"h-5 w-5 text-slate-700 transition-opacity duration-200 hover:text-slate-500",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAddProduct(organization.id)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.add_product")}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={-1}
|
||||
align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={product!.id}
|
||||
onValueChange={(v) => handleEnvironmentChangeByProduct(v)}>
|
||||
{sortedProducts.map((product) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={product.id}
|
||||
className="cursor-pointer break-all"
|
||||
key={product.id}>
|
||||
<div>
|
||||
{product.config.channel === "website" ? (
|
||||
<GlobeIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : product.config.channel === "app" ? (
|
||||
<GlobeLockIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : product.config.channel === "link" ? (
|
||||
<LinkIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
) : (
|
||||
<BlendIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
<div className="">{product?.name}</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{isOwnerOrAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAddProduct(organization.id)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.add_product")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
interface SideBarProps {
|
||||
environment: TEnvironment;
|
||||
environments: TEnvironment[];
|
||||
membershipRole?: TMembershipRole;
|
||||
membershipRole?: TOrganizationRole;
|
||||
productPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlBar = ({ environment, environments, membershipRole }: SideBarProps) => {
|
||||
export const TopControlBar = ({
|
||||
environment,
|
||||
environments,
|
||||
membershipRole,
|
||||
productPermission,
|
||||
}: SideBarProps) => {
|
||||
return (
|
||||
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
|
||||
<div className="shadow-xs z-10">
|
||||
@@ -19,6 +26,7 @@ export const TopControlBar = ({ environment, environments, membershipRole }: Sid
|
||||
environments={environments}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={membershipRole}
|
||||
productPermission={productPermission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
|
||||
interface TopControlButtonsProps {
|
||||
environment: TEnvironment;
|
||||
environments: TEnvironment[];
|
||||
isFormbricksCloud: boolean;
|
||||
membershipRole?: TMembershipRole;
|
||||
membershipRole?: TOrganizationRole;
|
||||
productPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlButtons = ({
|
||||
@@ -21,12 +25,18 @@ export const TopControlButtons = ({
|
||||
environments,
|
||||
isFormbricksCloud,
|
||||
membershipRole,
|
||||
productPermission,
|
||||
}: TopControlButtonsProps) => {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
return (
|
||||
<div className="z-50 flex items-center space-x-2">
|
||||
<EnvironmentSwitch environment={environment} environments={environments} />
|
||||
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
||||
{isFormbricksCloud && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
@@ -49,7 +59,9 @@ export const TopControlButtons = ({
|
||||
}}>
|
||||
<CircleUserIcon strokeWidth={1.5} className="h-5 w-5" />
|
||||
</Button>
|
||||
{membershipRole && membershipRole !== "viewer" ? (
|
||||
{isBilling || isReadOnly ? (
|
||||
<></>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
@@ -60,7 +72,7 @@ export const TopControlButtons = ({
|
||||
}}>
|
||||
<PlusIcon strokeWidth={1.5} className="h-5 w-5" />
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"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 { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||
|
||||
@@ -16,10 +16,20 @@ const ZCreateOrUpdateIntegrationAction = z.object({
|
||||
export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
.schema(ZCreateOrUpdateIntegrationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["integration", "create"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
@@ -32,10 +42,20 @@ const ZDeleteIntegrationAction = z.object({
|
||||
export const deleteIntegrationAction = authenticatedActionClient
|
||||
.schema(ZDeleteIntegrationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.integrationId),
|
||||
rules: ["integration", "delete"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.integrationId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await deleteIntegration(parsedInput.integrationId);
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
|
||||
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";
|
||||
import { getAirtableTables } from "@formbricks/lib/airtable/service";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
@@ -17,12 +24,18 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
const Page = async ({ params }) => {
|
||||
const t = await getTranslations();
|
||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||
const [surveys, integrations, environment, attributeClasses] = await Promise.all([
|
||||
const [session, surveys, integrations, environment, attributeClasses] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
@@ -42,6 +55,22 @@ const Page = async ({ params }) => {
|
||||
|
||||
const locale = findMatchingLocale();
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
session?.user.id,
|
||||
product.organizationId
|
||||
);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
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";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
@@ -9,6 +14,8 @@ import {
|
||||
} from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
@@ -20,12 +27,18 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
const Page = async ({ params }) => {
|
||||
const t = await getTranslations();
|
||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||
const [surveys, integrations, environment, attributeClasses] = await Promise.all([
|
||||
const [session, surveys, integrations, environment, attributeClasses] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
@@ -40,6 +53,22 @@ const Page = async ({ params }) => {
|
||||
|
||||
const locale = findMatchingLocale();
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
session?.user.id,
|
||||
product.organizationId
|
||||
);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
|
||||
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";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import {
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -10,7 +15,10 @@ import {
|
||||
} from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getIntegrationByType } from "@formbricks/lib/integration/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getNotionDatabases } from "@formbricks/lib/notion/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
@@ -26,23 +34,49 @@ const Page = async ({ params }) => {
|
||||
NOTION_AUTH_URL &&
|
||||
NOTION_REDIRECT_URI
|
||||
);
|
||||
const [surveys, notionIntegration, environment, attributeClasses] = await Promise.all([
|
||||
const [session, surveys, notionIntegration, environment, attributeClasses] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "notion"),
|
||||
getEnvironment(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
if (!product) {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
let databasesArray: TIntegrationNotionDatabase[] = [];
|
||||
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
||||
databasesArray = await getNotionDatabases(environment.id);
|
||||
}
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
session?.user.id,
|
||||
product.organizationId
|
||||
);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
|
||||
@@ -7,9 +7,12 @@ import notionLogo from "@/images/notion.png";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import WebhookLogo from "@/images/webhook.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
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 Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
@@ -18,7 +21,6 @@ import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getWebhookCountBySource } from "@formbricks/lib/webhook/service";
|
||||
import { TIntegrationType } from "@formbricks/types/integration";
|
||||
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
|
||||
import { Card } from "@formbricks/ui/components/IntegrationCard";
|
||||
import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
|
||||
import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
@@ -56,8 +58,22 @@ const Page = async ({ params }) => {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
||||
const isNotionIntegrationConnected = isIntegrationConnected("notion");
|
||||
@@ -84,6 +100,7 @@ const Page = async ({ params }) => {
|
||||
: zapierWebhookCount === 0
|
||||
? t("common.not_connected")
|
||||
: `${zapierWebhookCount} zaps`,
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/integrations/webhooks`,
|
||||
@@ -102,6 +119,7 @@ const Page = async ({ params }) => {
|
||||
: userWebhookCount === 0
|
||||
? t("common.not_connected")
|
||||
: `${userWebhookCount} webhooks`,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/integrations/google-sheets`,
|
||||
@@ -115,6 +133,7 @@ const Page = async ({ params }) => {
|
||||
icon: <Image src={GoogleSheetsLogo} alt="Google sheets Logo" />,
|
||||
connected: isGoogleSheetsIntegrationConnected,
|
||||
statusText: isGoogleSheetsIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/integrations/airtable`,
|
||||
@@ -128,6 +147,7 @@ const Page = async ({ params }) => {
|
||||
icon: <Image src={AirtableLogo} alt="Airtable Logo" />,
|
||||
connected: isAirtableIntegrationConnected,
|
||||
statusText: isAirtableIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/integrations/slack`,
|
||||
@@ -141,6 +161,7 @@ const Page = async ({ params }) => {
|
||||
icon: <Image src={SlackLogo} alt="Slack Logo" />,
|
||||
connected: isSlackIntegrationConnected,
|
||||
statusText: isSlackIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
docsHref: "https://formbricks.com/docs/integrations/n8n",
|
||||
@@ -159,6 +180,7 @@ const Page = async ({ params }) => {
|
||||
: n8nwebhookCount === 0
|
||||
? t("common.not_connected")
|
||||
: `${n8nwebhookCount} ${t("common.integrations")}`,
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
docsHref: "https://formbricks.com/docs/integrations/make",
|
||||
@@ -177,6 +199,7 @@ const Page = async ({ params }) => {
|
||||
: makeWebhookCount === 0
|
||||
? t("common.not_connected")
|
||||
: `${makeWebhookCount} ${t("common.integrations")}`,
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/integrations/notion`,
|
||||
@@ -190,6 +213,7 @@ const Page = async ({ params }) => {
|
||||
icon: <Image src={notionLogo} alt="Notion Logo" />,
|
||||
connected: isNotionIntegrationConnected,
|
||||
statusText: isNotionIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -205,10 +229,9 @@ const Page = async ({ params }) => {
|
||||
icon: <Image src={JsLogo} alt="Javascript Logo" />,
|
||||
connected: widgetSetupCompleted,
|
||||
statusText: widgetSetupCompleted ? t("common.connected") : t("common.not_connected"),
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
if (isViewer) return <ErrorComponent />;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.integrations")} />
|
||||
@@ -227,6 +250,7 @@ const Page = async ({ params }) => {
|
||||
icon={card.icon}
|
||||
connected={card.connected}
|
||||
statusText={card.statusText}
|
||||
disabled={card.disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 { getSlackChannels } from "@formbricks/lib/slack/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
@@ -14,10 +14,20 @@ const ZRefreshChannelsAction = z.object({
|
||||
export const refreshChannelsAction = authenticatedActionClient
|
||||
.schema(ZRefreshChannelsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["integration", "update"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getSlackChannels(parsedInput.environmentId);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
|
||||
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";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getIntegrationByType } from "@formbricks/lib/integration/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSlackChannels } from "@formbricks/lib/slack/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
@@ -16,22 +24,49 @@ import { PageHeader } from "@formbricks/ui/components/PageHeader";
|
||||
const Page = async ({ params }) => {
|
||||
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
|
||||
const t = await getTranslations();
|
||||
const [surveys, slackIntegration, environment, attributeClasses] = await Promise.all([
|
||||
const [session, surveys, slackIntegration, environment, attributeClasses] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "slack"),
|
||||
getEnvironment(params.environmentId),
|
||||
getAttributeClasses(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
if (!product) {
|
||||
throw new Error(t("common.product_not_found"));
|
||||
}
|
||||
|
||||
let channelsArray: TIntegrationItem[] = [];
|
||||
if (slackIntegration && slackIntegration.config.key) {
|
||||
channelsArray = await getSlackChannels(params.environmentId);
|
||||
}
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
session?.user.id,
|
||||
product.organizationId
|
||||
);
|
||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const productPermission = await getProductPermissionByUserId(session?.user.id, environment?.productId);
|
||||
|
||||
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
|
||||
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromWebhookId,
|
||||
} from "@formbricks/lib/organization/utils";
|
||||
getProductIdFromEnvironmentId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { createWebhook, deleteWebhook, updateWebhook } from "@formbricks/lib/webhook/service";
|
||||
import { testEndpoint } from "@formbricks/lib/webhook/utils";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -20,10 +21,20 @@ const ZCreateWebhookAction = z.object({
|
||||
export const createWebhookAction = authenticatedActionClient
|
||||
.schema(ZCreateWebhookAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
rules: ["webhook", "create"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "read",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await createWebhook(parsedInput.environmentId, parsedInput.webhookInput);
|
||||
@@ -36,10 +47,20 @@ const ZDeleteWebhookAction = z.object({
|
||||
export const deleteWebhookAction = authenticatedActionClient
|
||||
.schema(ZDeleteWebhookAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromWebhookId(parsedInput.id),
|
||||
rules: ["webhook", "delete"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.id),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await deleteWebhook(parsedInput.id);
|
||||
@@ -53,10 +74,20 @@ const ZUpdateWebhookAction = z.object({
|
||||
export const updateWebhookAction = authenticatedActionClient
|
||||
.schema(ZUpdateWebhookAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorization({
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromWebhookId(parsedInput.webhookId),
|
||||
rules: ["webhook", "update"],
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "productTeam",
|
||||
minPermission: "readWrite",
|
||||
productId: await getProductIdFromEnvironmentId(parsedInput.webhookId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateWebhook(parsedInput.webhookId, parsedInput.webhookInput);
|
||||
|
||||
@@ -25,44 +25,46 @@ export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id="allSurveys"
|
||||
className="bg-white"
|
||||
value=""
|
||||
checked={selectedAllSurveys}
|
||||
onCheckedChange={onSelectAllSurveys}
|
||||
disabled={!allowChanges}
|
||||
/>
|
||||
<label
|
||||
htmlFor="allSurveys"
|
||||
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""} ${
|
||||
!allowChanges ? "cursor-not-allowed opacity-50" : ""
|
||||
className={`flex items-center ${selectedAllSurveys ? "font-semibold" : ""} ${
|
||||
!allowChanges ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
}`}>
|
||||
{t("environments.integrations.webhooks.all_current_and_new_surveys")}
|
||||
<Checkbox
|
||||
type="button"
|
||||
id="allSurveys"
|
||||
className="bg-white"
|
||||
value=""
|
||||
checked={selectedAllSurveys}
|
||||
onCheckedChange={onSelectAllSurveys}
|
||||
disabled={!allowChanges}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{t("environments.integrations.webhooks.all_current_and_new_surveys")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{surveys.map((survey) => (
|
||||
<div key={survey.id} className="my-1 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={survey.id}
|
||||
value={survey.id}
|
||||
className="bg-white"
|
||||
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
|
||||
disabled={selectedAllSurveys || !allowChanges}
|
||||
onCheckedChange={() => {
|
||||
if (allowChanges) {
|
||||
onSelectedSurveyChange(survey.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={survey.id}
|
||||
className={`flex cursor-pointer items-center ${
|
||||
selectedAllSurveys ? "cursor-not-allowed opacity-50" : ""
|
||||
} ${!allowChanges ? "cursor-not-allowed opacity-50" : ""}`}>
|
||||
{survey.name}
|
||||
className={`flex items-center ${
|
||||
selectedAllSurveys || !allowChanges ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
}`}>
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={survey.id}
|
||||
value={survey.id}
|
||||
className="bg-white"
|
||||
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
|
||||
disabled={selectedAllSurveys || !allowChanges}
|
||||
onCheckedChange={() => {
|
||||
if (allowChanges) {
|
||||
onSelectedSurveyChange(survey.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{survey.name}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -11,9 +11,10 @@ interface WebhookModalProps {
|
||||
setOpen: (v: boolean) => void;
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const WebhookModal = ({ open, setOpen, webhook, surveys }: WebhookModalProps) => {
|
||||
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
|
||||
const t = useTranslations();
|
||||
const tabs = [
|
||||
{
|
||||
@@ -22,7 +23,9 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys }: WebhookModalPr
|
||||
},
|
||||
{
|
||||
title: t("common.settings"),
|
||||
children: <WebhookSettingsTab webhook={webhook} surveys={surveys} setOpen={setOpen} />,
|
||||
children: (
|
||||
<WebhookSettingsTab webhook={webhook} surveys={surveys} setOpen={setOpen} isReadOnly={isReadOnly} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ interface ActionSettingsTabProps {
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettingsTabProps) => {
|
||||
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: ActionSettingsTabProps) => {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit } = useForm({
|
||||
@@ -136,6 +137,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings
|
||||
type="text"
|
||||
id="name"
|
||||
{...register("name")}
|
||||
disabled={isReadOnly}
|
||||
defaultValue={webhook.name ?? ""}
|
||||
placeholder={t("environments.integrations.webhooks.webhook_name_placeholder")}
|
||||
/>
|
||||
@@ -185,7 +187,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings
|
||||
<TriggerCheckboxGroup
|
||||
selectedTriggers={selectedTriggers}
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
allowChanges={webhook.source === "user"}
|
||||
allowChanges={webhook.source === "user" && !isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -197,13 +199,13 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings
|
||||
selectedAllSurveys={selectedAllSurveys}
|
||||
onSelectAllSurveys={handleSelectAllSurveys}
|
||||
onSelectedSurveyChange={handleSelectedSurveyChange}
|
||||
allowChanges={webhook.source === "user"}
|
||||
allowChanges={webhook.source === "user" && !isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
{webhook.source === "user" && (
|
||||
{webhook.source === "user" && !isReadOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
@@ -222,11 +224,13 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" loading={isUpdatingWebhook}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" loading={isUpdatingWebhook}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<DeleteDialog
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user