feat: Product Model Revamp (#4353)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2024-12-03 10:04:09 +05:30
committed by GitHub
parent 5dcd32050a
commit 35b2d12e18
315 changed files with 4344 additions and 3587 deletions
@@ -7,9 +7,9 @@ import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromResponseId,
getOrganizationIdFromResponseNoteId,
getProductIdFromEnvironmentId,
getProductIdFromResponseId,
getProductIdFromResponseNoteId,
getProjectIdFromEnvironmentId,
getProjectIdFromResponseId,
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { z } from "zod";
@@ -40,8 +40,8 @@ export const createTagAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
@@ -78,8 +78,8 @@ export const createTagToResponseAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromEnvironmentId(responseEnvironmentId),
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
@@ -116,8 +116,8 @@ export const deleteTagOnResponseAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromEnvironmentId(responseEnvironmentId),
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
@@ -142,8 +142,8 @@ export const deleteResponseAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromResponseId(parsedInput.responseId),
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
@@ -169,8 +169,8 @@ export const updateResponseNoteAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromResponseNoteId(parsedInput.responseNoteId),
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
@@ -195,8 +195,8 @@ export const resolveResponseNoteAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromResponseNoteId(parsedInput.responseNoteId),
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
@@ -222,8 +222,8 @@ export const createResponseNoteAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromResponseId(parsedInput.responseId),
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
@@ -248,9 +248,9 @@ export const getResponseAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "read",
productId: await getProductIdFromResponseId(parsedInput.responseId),
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
},
],
});
@@ -66,7 +66,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
size="sm"
className="cursor-pointer p-0"
onClick={() => {
router.push(`/environments/${environmentId}/product/tags`);
router.push(`/environments/${environmentId}/project/tags`);
}}>
<SettingsIcon className="h-5 w-5 text-slate-500 hover:text-slate-600" />
</Button>
@@ -8,9 +8,9 @@ import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromSegmentId,
getOrganizationIdFromSurveyId,
getProductIdFromEnvironmentId,
getProductIdFromSegmentId,
getProductIdFromSurveyId,
getProjectIdFromEnvironmentId,
getProjectIdFromSegmentId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { getAdvancedTargetingPermission } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
@@ -63,9 +63,9 @@ export const createSegmentAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -104,9 +104,9 @@ export const updateSegmentAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromSegmentId(parsedInput.segmentId),
projectId: await getProjectIdFromSegmentId(parsedInput.segmentId),
},
],
});
@@ -153,9 +153,9 @@ export const loadNewSegmentAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromEnvironmentId(surveyEnvironmentId),
projectId: await getProjectIdFromEnvironmentId(surveyEnvironmentId),
},
],
});
@@ -191,9 +191,9 @@ export const cloneSegmentAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromEnvironmentId(surveyEnvironmentId),
projectId: await getProjectIdFromEnvironmentId(surveyEnvironmentId),
},
],
});
@@ -221,9 +221,9 @@ export const deleteSegmentAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromSegmentId(parsedInput.segmentId),
projectId: await getProjectIdFromSegmentId(parsedInput.segmentId),
},
],
});
@@ -251,9 +251,9 @@ export const resetSegmentFiltersAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
@@ -9,6 +9,7 @@ export const CLOUD_PRICING_DATA = {
mainFeatures: [
"environments.settings.billing.unlimited_surveys",
"environments.settings.billing.unlimited_team_members",
"environments.settings.billing.3_projects",
"environments.settings.billing.1500_monthly_responses",
"environments.settings.billing.2000_monthly_identified_users",
"environments.settings.billing.website_surveys",
@@ -33,6 +34,7 @@ export const CLOUD_PRICING_DATA = {
"environments.settings.billing.unlimited_surveys",
"environments.settings.billing.remove_branding",
"environments.settings.billing.email_support",
"environments.settings.billing.3_projects",
"environments.settings.billing.5000_monthly_responses",
"environments.settings.billing.7500_monthly_identified_users",
],
@@ -50,6 +52,7 @@ export const CLOUD_PRICING_DATA = {
"environments.settings.billing.multi_language_surveys",
"environments.settings.billing.advanced_targeting",
"environments.settings.billing.priority_support",
"environments.settings.billing.5_projects",
"environments.settings.billing.10000_monthly_responses",
"environments.settings.billing.30000_monthly_identified_users",
],
@@ -66,6 +69,7 @@ export const CLOUD_PRICING_DATA = {
},
mainFeatures: [
"environments.settings.billing.everything_in_scale",
"environments.settings.billing.custom_project_limit",
"environments.settings.billing.custom_miu_limit",
"environments.settings.billing.premium_support_with_slas",
"environments.settings.billing.uptime_sla_99",
@@ -1,5 +1,5 @@
import Stripe from "stripe";
import { PRODUCT_FEATURE_KEYS, STRIPE_API_VERSION } from "@formbricks/lib/constants";
import { PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@formbricks/lib/constants";
import { env } from "@formbricks/lib/env";
import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -53,6 +53,7 @@ export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) =>
let responses: number | null = null;
let miu: number | null = null;
let projects: number | null = null;
if (product.metadata.responses === "unlimited") {
responses = null;
@@ -72,23 +73,32 @@ export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) =>
throw new Error("Invalid miu metadata in product");
}
if (product.metadata.projects === "unlimited") {
projects = null;
} else if (parseInt(product.metadata.projects) > 0) {
projects = parseInt(product.metadata.projects);
} else {
console.error("Invalid projects metadata in product: ", product.metadata.projects);
throw new Error("Invalid projects metadata in product");
}
const plan = ZOrganizationBillingPlan.parse(product.metadata.plan);
switch (plan) {
case PRODUCT_FEATURE_KEYS.FREE:
updatedBillingPlan = PRODUCT_FEATURE_KEYS.STARTUP;
case PROJECT_FEATURE_KEYS.FREE:
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
break;
case PRODUCT_FEATURE_KEYS.STARTUP:
updatedBillingPlan = PRODUCT_FEATURE_KEYS.STARTUP;
case PROJECT_FEATURE_KEYS.STARTUP:
updatedBillingPlan = PROJECT_FEATURE_KEYS.STARTUP;
break;
case PRODUCT_FEATURE_KEYS.SCALE:
updatedBillingPlan = PRODUCT_FEATURE_KEYS.SCALE;
case PROJECT_FEATURE_KEYS.SCALE:
updatedBillingPlan = PROJECT_FEATURE_KEYS.SCALE;
break;
case PRODUCT_FEATURE_KEYS.ENTERPRISE:
updatedBillingPlan = PRODUCT_FEATURE_KEYS.ENTERPRISE;
case PROJECT_FEATURE_KEYS.ENTERPRISE:
updatedBillingPlan = PROJECT_FEATURE_KEYS.ENTERPRISE;
break;
}
@@ -99,6 +109,7 @@ export const handleSubscriptionCreatedOrUpdated = async (event: Stripe.Event) =>
plan: updatedBillingPlan,
period,
limits: {
projects,
monthly: {
responses,
miu,
@@ -1,5 +1,5 @@
import Stripe from "stripe";
import { BILLING_LIMITS, PRODUCT_FEATURE_KEYS } from "@formbricks/lib/constants";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@formbricks/lib/constants";
import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -17,8 +17,9 @@ export const handleSubscriptionDeleted = async (event: Stripe.Event) => {
await updateOrganization(organizationId, {
billing: {
...organization.billing,
plan: PRODUCT_FEATURE_KEYS.FREE,
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
@@ -23,7 +23,7 @@ interface PricingCardProps {
organization: TOrganization;
onUpgrade: () => Promise<void>;
onManageSubscription: () => Promise<void>;
productFeatureKeys: {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
SCALE: string;
@@ -37,20 +37,20 @@ export const PricingCard = ({
onUpgrade,
onManageSubscription,
organization,
productFeatureKeys,
projectFeatureKeys,
}: PricingCardProps) => {
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const isCurrentPlan = useMemo(() => {
if (organization.billing.plan === productFeatureKeys.FREE && plan.id === productFeatureKeys.FREE) {
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
return true;
}
if (
organization.billing.plan === productFeatureKeys.ENTERPRISE &&
plan.id === productFeatureKeys.ENTERPRISE
organization.billing.plan === projectFeatureKeys.ENTERPRISE &&
plan.id === projectFeatureKeys.ENTERPRISE
) {
return true;
}
@@ -61,8 +61,8 @@ export const PricingCard = ({
organization.billing.plan,
plan.id,
planPeriod,
productFeatureKeys.ENTERPRISE,
productFeatureKeys.FREE,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.FREE,
]);
const CTAButton = useMemo(() => {
@@ -70,8 +70,8 @@ export const PricingCard = ({
return null;
}
if (plan.id !== productFeatureKeys.ENTERPRISE && plan.id !== productFeatureKeys.FREE) {
if (organization.billing.plan === productFeatureKeys.FREE) {
if (plan.id !== projectFeatureKeys.ENTERPRISE && plan.id !== projectFeatureKeys.FREE) {
if (organization.billing.plan === projectFeatureKeys.FREE) {
return (
<Button
loading={loading}
@@ -105,8 +105,8 @@ export const PricingCard = ({
onUpgrade,
organization.billing.plan,
plan.id,
productFeatureKeys.ENTERPRISE,
productFeatureKeys.FREE,
projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.FREE,
]);
return (
@@ -139,7 +139,7 @@ export const PricingCard = ({
plan.featured ? "text-slate-900" : "text-slate-800",
"text-4xl font-bold tracking-tight"
)}>
{plan.id !== productFeatureKeys.ENTERPRISE
{plan.id !== projectFeatureKeys.ENTERPRISE
? planPeriod === "monthly"
? plan.price.monthly
: plan.price.yearly
@@ -156,7 +156,7 @@ export const PricingCard = ({
{CTAButton}
{plan.id !== productFeatureKeys.FREE && isCurrentPlan && (
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
<Button
variant="secondary"
loading={loading}
@@ -170,7 +170,7 @@ export const PricingCard = ({
</Button>
)}
{organization.billing.plan !== plan.id && plan.id === productFeatureKeys.ENTERPRISE && (
{organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && (
<Button loading={loading} onClick={() => onUpgrade()} className="flex justify-center">
{t("environments.settings.billing.contact_us")}
</Button>
@@ -19,13 +19,14 @@ interface PricingTableProps {
environmentId: string;
peopleCount: number;
responseCount: number;
projectCount: number;
stripePriceLookupKeys: {
STARTUP_MONTHLY: string;
STARTUP_YEARLY: string;
SCALE_MONTHLY: string;
SCALE_YEARLY: string;
};
productFeatureKeys: {
projectFeatureKeys: {
FREE: string;
STARTUP: string;
SCALE: string;
@@ -38,8 +39,9 @@ export const PricingTable = ({
environmentId,
organization,
peopleCount,
productFeatureKeys,
projectFeatureKeys,
responseCount,
projectCount,
stripePriceLookupKeys,
hasBillingRights,
}: PricingTableProps) => {
@@ -137,6 +139,8 @@ export const PricingTable = ({
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.responses === null;
const peopleUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.monthly.miu === null;
const projectsUnlimitedCheck =
organization.billing.plan === "enterprise" && organization.billing.limits.projects === null;
return (
<main>
@@ -197,7 +201,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-12",
"relative mx-8 mb-8 flex flex-col gap-4",
peopleUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">
@@ -205,7 +209,7 @@ export const PricingTable = ({
</p>
{organization.billing.limits.monthly.miu && (
<BillingSlider
className="slider-class"
className="slider-class mb-8"
value={peopleCount}
max={organization.billing.limits.monthly.miu * 1.5}
freeTierLimit={organization.billing.limits.monthly.miu}
@@ -213,7 +217,34 @@ export const PricingTable = ({
/>
)}
{peopleUnlimitedCheck && <Badge text="Unlimited MIU" type="success" size="normal" />}
{peopleUnlimitedCheck && (
<Badge text={t("environments.settings.billing.unlimited_miu")} type="success" size="normal" />
)}
</div>
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-12",
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
{organization.billing.limits.projects && (
<BillingSlider
className="slider-class mb-8"
value={projectCount}
max={organization.billing.limits.projects * 1.5}
freeTierLimit={organization.billing.limits.projects}
metric={t("common.projects")}
/>
)}
{projectsUnlimitedCheck && (
<Badge
text={t("environments.settings.billing.unlimited_projects")}
type="success"
size="normal"
/>
)}
</div>
</div>
</div>
@@ -254,7 +285,7 @@ export const PricingTable = ({
await onUpgrade(plan.id);
}}
organization={organization}
productFeatureKeys={productFeatureKeys}
projectFeatureKeys={projectFeatureKeys}
onManageSubscription={openCustomerPortal}
/>
))}
+6 -3
View File
@@ -7,7 +7,7 @@ import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { PRODUCT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants";
import { PROJECT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import {
@@ -15,6 +15,7 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@formbricks/lib/organization/service";
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
import { PricingTable } from "./components/pricing-table";
export const PricingPage = async (props) => {
@@ -35,9 +36,10 @@ export const PricingPage = async (props) => {
throw new Error(t("common.not_authorized"));
}
const [peopleCount, responseCount] = await Promise.all([
const [peopleCount, responseCount, projectCount] = await Promise.all([
getMonthlyActiveOrganizationPeopleCount(organization.id),
getMonthlyOrganizationResponseCount(organization.id),
getOrganizationProjectsCount(organization.id),
]);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
@@ -63,8 +65,9 @@ export const PricingPage = async (props) => {
environmentId={params.environmentId}
peopleCount={peopleCount}
responseCount={responseCount}
projectCount={projectCount}
stripePriceLookupKeys={STRIPE_PRICE_LOOKUP_KEYS}
productFeatureKeys={PRODUCT_FEATURE_KEYS}
projectFeatureKeys={PROJECT_FEATURE_KEYS}
hasBillingRights={hasBillingRights}
/>
</PageContentWrapper>
+3 -3
View File
@@ -3,7 +3,7 @@
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProductIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getIsAIEnabled, getIsOrganizationAIReady } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service";
@@ -45,8 +45,8 @@ export const generateInsightsForSurveyAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromSurveyId(parsedInput.surveyId),
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -8,9 +8,9 @@ import {
getOrganizationIdFromDocumentId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromInsightId,
getProductIdFromDocumentId,
getProductIdFromEnvironmentId,
getProductIdFromInsightId,
getProjectIdFromDocumentId,
getProjectIdFromEnvironmentId,
getProjectIdFromInsightId,
} from "@/lib/utils/helper";
import { checkAIPermission } from "@/modules/ee/insights/actions";
import {
@@ -52,9 +52,9 @@ export const getDocumentsByInsightIdSurveyIdQuestionIdAction = authenticatedActi
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "read",
productId: await getProductIdFromEnvironmentId(surveyEnvironmentId),
projectId: await getProjectIdFromEnvironmentId(surveyEnvironmentId),
},
],
});
@@ -90,9 +90,9 @@ export const getDocumentsByInsightIdAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "read",
productId: await getProductIdFromInsightId(parsedInput.insightId),
projectId: await getProjectIdFromInsightId(parsedInput.insightId),
},
],
});
@@ -129,9 +129,9 @@ export const updateDocumentAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromDocumentId(parsedInput.documentId),
projectId: await getProjectIdFromDocumentId(parsedInput.documentId),
},
],
});
@@ -5,8 +5,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromInsightId,
getProductIdFromEnvironmentId,
getProductIdFromInsightId,
getProjectIdFromEnvironmentId,
getProjectIdFromInsightId,
} from "@/lib/utils/helper";
import { checkAIPermission } from "@/modules/ee/insights/actions";
import { z } from "zod";
@@ -35,9 +35,9 @@ export const getEnvironmentInsightsAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "read",
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -70,9 +70,9 @@ export const getStatsAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "read",
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -101,8 +101,8 @@ export const updateInsightAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromInsightId(parsedInput.insightId),
type: "projectTeam",
projectId: await getProjectIdFromInsightId(parsedInput.insightId),
minPermission: "readWrite",
},
],
@@ -9,13 +9,13 @@ import { Tabs, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TProject } from "@formbricks/types/project";
import { TUser, TUserLocale } from "@formbricks/types/user";
interface DashboardProps {
user: TUser;
environment: TEnvironment;
product: TProduct;
project: TProject;
insightsPerPage: number;
documentsPerPage: number;
locale: TUserLocale;
@@ -23,7 +23,7 @@ interface DashboardProps {
export const Dashboard = ({
environment,
product,
project,
user,
insightsPerPage,
documentsPerPage,
@@ -65,7 +65,7 @@ export const Dashboard = ({
<ExperiencePageStats statsFrom={statsFrom} environmentId={environment.id} />
<InsightsCard
statsFrom={statsFrom}
productName={product.name}
projectName={project.name}
environmentId={environment.id}
insightsPerPage={insightsPerPage}
documentsPerPage={documentsPerPage}
@@ -8,7 +8,7 @@ import { InsightView } from "./insight-view";
interface InsightsCardProps {
environmentId: string;
insightsPerPage: number;
productName: string;
projectName: string;
statsFrom?: Date;
documentsPerPage: number;
locale: TUserLocale;
@@ -17,7 +17,7 @@ interface InsightsCardProps {
export const InsightsCard = ({
statsFrom,
environmentId,
productName,
projectName,
insightsPerPage: insightsLimit,
documentsPerPage,
locale,
@@ -26,7 +26,7 @@ export const InsightsCard = ({
return (
<Card>
<CardHeader>
<CardTitle>{t("environments.experience.insights_for_product", { productName })}</CardTitle>
<CardTitle>{t("environments.experience.insights_for_project", { projectName })}</CardTitle>
<CardDescription>{t("environments.experience.insights_description")}</CardDescription>
</CardHeader>
<CardContent>
@@ -4,18 +4,18 @@ import { TemplateList } from "@/modules/surveys/components/TemplateList";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/modules/ui/components/card";
import { useTranslations } from "next-intl";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TProject } from "@formbricks/types/project";
import { TTemplateFilter } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
interface TemplatesCardProps {
environment: TEnvironment;
product: TProduct;
project: TProject;
user: TUser;
prefilledFilters: TTemplateFilter[];
}
export const TemplatesCard = ({ environment, product, user, prefilledFilters }: TemplatesCardProps) => {
export const TemplatesCard = ({ environment, project, user, prefilledFilters }: TemplatesCardProps) => {
const t = useTranslations();
return (
<Card>
@@ -26,7 +26,7 @@ export const TemplatesCard = ({ environment, product, user, prefilledFilters }:
<CardContent>
<TemplateList
environment={environment}
product={product}
project={project}
showFilters={false}
user={user}
prefilledFilters={prefilledFilters}
@@ -9,7 +9,7 @@ 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 { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
@@ -26,9 +26,9 @@ export const ExperiencePage = async (props) => {
throw new Error("User not found");
}
const [environment, product, organization] = await Promise.all([
const [environment, project, organization] = await Promise.all([
getEnvironment(params.environmentId),
getProductByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
@@ -36,8 +36,8 @@ export const ExperiencePage = async (props) => {
throw new Error("Environment not found");
}
if (!product) {
throw new Error("Product not found");
if (!project) {
throw new Error("Project not found");
}
if (!organization) {
@@ -62,7 +62,7 @@ export const ExperiencePage = async (props) => {
<Dashboard
environment={environment}
insightsPerPage={INSIGHTS_PER_PAGE}
product={product}
project={project}
user={user}
documentsPerPage={DOCUMENTS_PER_PAGE}
locale={locale}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { LanguageLabels } from "@/modules/ee/multi-language-surveys/components/language-labels";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
export const LanguagesLoading = () => {
const t = useTranslations();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="languages" loading />
</PageHeader>
<SettingsCard
title={t("environments.project.languages.multi_language_surveys")}
description={t("environments.project.languages.multi_language_surveys_description")}>
<div className="flex flex-col space-y-4">
<LanguageLabels />
{[...Array(3)].map((_, idx) => (
<div key={idx} className="my-3 grid h-10 grid-cols-4 gap-4">
<div className="h-full animate-pulse rounded-md bg-slate-200" />
<div className="h-full animate-pulse rounded-md bg-slate-200" />
<div className="h-full animate-pulse rounded-md bg-slate-200" />
</div>
))}
</div>
</SettingsCard>
</PageContentWrapper>
);
};
+81
View File
@@ -0,0 +1,81 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { EditLanguage } from "@/modules/ee/multi-language-surveys/components/edit-language";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
export const LanguagesPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslations();
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const organization = await getOrganization(project?.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
if (!isMultiLanguageAllowed) {
notFound();
}
const canDoRoleManagement = await getRoleManagementPermission(organization);
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Session not found");
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="languages"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("environments.project.languages.multi_language_surveys")}
description={t("environments.project.languages.multi_language_surveys_description")}>
<EditLanguage project={project} locale={user.locale} isReadOnly={isReadOnly} />
</SettingsCard>
</PageContentWrapper>
);
};
+42 -16
View File
@@ -14,7 +14,7 @@ import {
ENTERPRISE_LICENSE_KEY,
IS_AI_CONFIGURED,
IS_FORMBRICKS_CLOUD,
PRODUCT_FEATURE_KEYS,
PROJECT_FEATURE_KEYS,
} from "@formbricks/lib/constants";
import { env } from "@formbricks/lib/env";
import { hashString } from "@formbricks/lib/hashString";
@@ -81,7 +81,7 @@ const fetchLicenseForE2ETesting = async (): Promise<{
// first call
const newResult = {
active: true,
features: { isMultiOrgEnabled: true, twoFactorAuth: true, sso: true },
features: { isMultiOrgEnabled: true, projects: 3, twoFactorAuth: true, sso: true },
lastChecked: currentTime,
};
await setPreviousResult(newResult);
@@ -138,7 +138,7 @@ export const getEnterpriseLicense = async (): Promise<{
if (isValid === null) {
const newResult = {
active: false,
features: { isMultiOrgEnabled: false, twoFactorAuth: false, sso: false },
features: { isMultiOrgEnabled: false, projects: 3, twoFactorAuth: false, sso: false },
lastChecked: new Date(),
};
@@ -244,28 +244,32 @@ export const fetchLicense = reactCache(
);
export const getRemoveInAppBrandingPermission = (organization: TOrganization): boolean => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return true;
return false;
};
export const getRemoveLinkBrandingPermission = (organization: TOrganization): boolean => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return true;
return false;
};
export const getSurveyFollowUpsPermission = async (organization: TOrganization): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return false;
};
export const getRoleManagementPermission = async (organization: TOrganization): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.active !== null ? previousResult.active : false;
}
if (IS_FORMBRICKS_CLOUD)
return (
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
organization.billing.plan === PROJECT_FEATURE_KEYS.SCALE ||
organization.billing.plan === PROJECT_FEATURE_KEYS.ENTERPRISE
);
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return false;
@@ -274,15 +278,15 @@ export const getRoleManagementPermission = async (organization: TOrganization):
export const getAdvancedTargetingPermission = async (organization: TOrganization): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD)
return (
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
organization.billing.plan === PROJECT_FEATURE_KEYS.SCALE ||
organization.billing.plan === PROJECT_FEATURE_KEYS.ENTERPRISE
);
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
else return false;
};
export const getBiggerUploadFileSizePermission = async (organization: TOrganization): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return false;
};
@@ -294,8 +298,8 @@ export const getMultiLanguagePermission = async (organization: TOrganization): P
}
if (IS_FORMBRICKS_CLOUD)
return (
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
organization.billing.plan === PROJECT_FEATURE_KEYS.SCALE ||
organization.billing.plan === PROJECT_FEATURE_KEYS.ENTERPRISE
);
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return false;
@@ -337,9 +341,9 @@ export const getIsOrganizationAIReady = async (billingPlan: TOrganizationBilling
return (
IS_AI_CONFIGURED &&
(await getEnterpriseLicense()).active &&
(billingPlan === PRODUCT_FEATURE_KEYS.STARTUP ||
billingPlan === PRODUCT_FEATURE_KEYS.SCALE ||
billingPlan === PRODUCT_FEATURE_KEYS.ENTERPRISE)
(billingPlan === PROJECT_FEATURE_KEYS.STARTUP ||
billingPlan === PROJECT_FEATURE_KEYS.SCALE ||
billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE)
);
}
@@ -349,3 +353,25 @@ export const getIsOrganizationAIReady = async (billingPlan: TOrganizationBilling
export const getIsAIEnabled = async (organization: TOrganization) => {
return organization.isAIEnabled && (await getIsOrganizationAIReady(organization.billing.plan));
};
export const getOrganizationProjectsLimit = async (organization: TOrganization): Promise<number> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult && previousResult.features ? (previousResult.features.projects ?? Infinity) : 3;
}
let limit: number;
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
limit = organization.billing.limits.projects ?? Infinity;
} else {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) {
limit = 3;
} else {
limit = licenseFeatures.projects ?? Infinity;
}
}
return limit;
};
@@ -6,6 +6,7 @@ export type TEnterpriseLicenseStatus = z.infer<typeof ZEnterpriseLicenseStatus>;
const ZEnterpriseLicenseFeatures = z.object({
isMultiOrgEnabled: z.boolean(),
projects: z.number().nullable(),
twoFactorAuth: z.boolean(),
sso: z.boolean(),
});
@@ -8,13 +8,13 @@ import {
} from "@/modules/ui/components/select";
import { useTranslations } from "next-intl";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import type { TLanguage, TProduct } from "@formbricks/types/product";
import type { TLanguage, TProject } from "@formbricks/types/project";
import type { ConfirmationModalProps } from "./multi-language-card";
interface DefaultLanguageSelectProps {
defaultLanguage?: TLanguage;
handleDefaultLanguageChange: (languageCode: string) => void;
product: TProduct;
project: TProject;
setConfirmationModalInfo: (confirmationModal: ConfirmationModalProps) => void;
locale: string;
}
@@ -22,7 +22,7 @@ interface DefaultLanguageSelectProps {
export function DefaultLanguageSelect({
defaultLanguage,
handleDefaultLanguageChange,
product,
project,
setConfirmationModalInfo,
locale,
}: DefaultLanguageSelectProps) {
@@ -60,7 +60,7 @@ export function DefaultLanguageSelect({
<SelectValue />
</SelectTrigger>
<SelectContent>
{product.languages.map((language) => (
{project.languages.map((language) => (
<SelectItem
className="xs:text-base px-0.5 py-1 text-xs text-slate-800 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
key={language.id}
@@ -8,7 +8,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { iso639Languages } from "@formbricks/lib/i18n/utils";
import type { TLanguage, TProduct } from "@formbricks/types/product";
import type { TLanguage, TProject } from "@formbricks/types/project";
import { TUserLocale } from "@formbricks/types/user";
import {
createLanguageAction,
@@ -20,7 +20,7 @@ import { LanguageLabels } from "./language-labels";
import { LanguageRow } from "./language-row";
interface EditLanguageProps {
product: TProduct;
project: TProject;
locale: TUserLocale;
isReadOnly: boolean;
}
@@ -36,19 +36,19 @@ const validateLanguages = (languages: TLanguage[], t: (key: string) => string) =
.map((language) => language.alias!.toLowerCase().trim());
if (languageCodes.includes("")) {
toast.error(t("environments.product.languages.please_select_a_language"), { duration: 2000 });
toast.error(t("environments.project.languages.please_select_a_language"), { duration: 2000 });
return false;
}
// Check for duplicates within the languageCodes and languageAliases
if (checkIfDuplicateExists(languageAliases) || checkIfDuplicateExists(languageCodes)) {
toast.error(t("environments.product.languages.duplicate_language_or_language_id"), { duration: 4000 });
toast.error(t("environments.project.languages.duplicate_language_or_language_id"), { duration: 4000 });
return false;
}
// Check if any alias matches the identifier of any added languages
if (languageCodes.some((code) => languageAliases.includes(code))) {
toast.error(t("environments.product.languages.conflict_between_identifier_and_alias"), {
toast.error(t("environments.project.languages.conflict_between_identifier_and_alias"), {
duration: 6000,
});
return false;
@@ -57,7 +57,7 @@ const validateLanguages = (languages: TLanguage[], t: (key: string) => string) =
// Check if the chosen alias matches an ISO identifier of a language that hasnt been added
for (const alias of languageAliases) {
if (iso639Languages.some((language) => language.alpha2 === alias && !languageCodes.includes(alias))) {
toast.error(t("environments.product.languages.conflict_between_selected_alias_and_another_language"), {
toast.error(t("environments.project.languages.conflict_between_selected_alias_and_another_language"), {
duration: 6000,
});
return false;
@@ -67,9 +67,9 @@ const validateLanguages = (languages: TLanguage[], t: (key: string) => string) =
return true;
};
export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps) {
export function EditLanguage({ project, locale, isReadOnly }: EditLanguageProps) {
const t = useTranslations();
const [languages, setLanguages] = useState<TLanguage[]>(product.languages);
const [languages, setLanguages] = useState<TLanguage[]>(project.languages);
const [isEditing, setIsEditing] = useState(false);
const [confirmationModal, setConfirmationModal] = useState({
isOpen: false,
@@ -79,8 +79,8 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
});
useEffect(() => {
setLanguages(product.languages);
}, [product.languages]);
setLanguages(project.languages);
}, [project.languages]);
const handleAddLanguage = () => {
const newLanguage = { id: "new", createdAt: new Date(), updatedAt: new Date(), code: "", alias: "" };
@@ -102,14 +102,14 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
setConfirmationModal({
isOpen: true,
languageId,
text: `${t("environments.product.languages.cannot_remove_language_warning")}:\n\n${surveyList}\n\n${t("environments.product.languages.remove_language_from_surveys_to_remove_it_from_product")}`,
text: `${t("environments.project.languages.cannot_remove_language_warning")}:\n\n${surveyList}\n\n${t("environments.project.languages.remove_language_from_surveys_to_remove_it_from_project")}`,
isButtonDisabled: true,
});
} else {
setConfirmationModal({
isOpen: true,
languageId,
text: t("environments.product.languages.delete_language_confirmation"),
text: t("environments.project.languages.delete_language_confirmation"),
isButtonDisabled: false,
});
}
@@ -124,9 +124,9 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
const performLanguageDeletion = async (languageId: string) => {
try {
await deleteLanguageAction({ languageId, productId: product.id });
await deleteLanguageAction({ languageId, projectId: project.id });
setLanguages((prev) => prev.filter((lang) => lang.id !== languageId));
toast.success(t("environments.product.languages.language_deleted_successfully"));
toast.success(t("environments.project.languages.language_deleted_successfully"));
// Close the modal after deletion
setConfirmationModal((prev) => ({ ...prev, isOpen: false }));
} catch (err) {
@@ -136,7 +136,7 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
};
const handleCancelChanges = async () => {
setLanguages(product.languages);
setLanguages(project.languages);
setIsEditing(false);
};
@@ -146,24 +146,24 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
languages.map((lang) => {
return lang.id === "new"
? createLanguageAction({
productId: product.id,
projectId: project.id,
languageInput: { code: lang.code, alias: lang.alias },
})
: updateLanguageAction({
productId: product.id,
projectId: project.id,
languageId: lang.id,
languageInput: { code: lang.code, alias: lang.alias },
});
})
);
toast.success(t("environments.product.languages.languages_updated_successfully"));
toast.success(t("environments.project.languages.languages_updated_successfully"));
setIsEditing(false);
};
const AddLanguageButton: React.FC<{ onClick: () => void }> = ({ onClick }) =>
isEditing && languages.length === product.languages.length ? (
isEditing && languages.length === project.languages.length ? (
<Button onClick={onClick} size="sm" variant="secondary" StartIcon={PlusIcon}>
{t("environments.product.languages.add_language")}
{t("environments.project.languages.add_language")}
</Button>
) : null;
@@ -191,7 +191,7 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
</>
) : (
<p className="text-sm italic text-slate-500">
{t("environments.product.languages.no_language_found")}
{t("environments.project.languages.no_language_found")}
</p>
)}
<AddLanguageButton onClick={handleAddLanguage} />
@@ -208,7 +208,7 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
/>
)}
<ConfirmationModal
buttonText={t("environments.product.languages.remove_language")}
buttonText={t("environments.project.languages.remove_language")}
isButtonDisabled={confirmationModal.isButtonDisabled}
onConfirm={() => performLanguageDeletion(confirmationModal.languageId)}
open={confirmationModal.isOpen}
@@ -216,7 +216,7 @@ export function EditLanguage({ product, locale, isReadOnly }: EditLanguageProps)
setConfirmationModal((prev) => ({ ...prev, isOpen: !prev.isOpen }));
}}
text={confirmationModal.text}
title={t("environments.product.languages.remove_language")}
title={t("environments.project.languages.remove_language")}
/>
</div>
);
@@ -240,6 +240,6 @@ const EditSaveButtons: React.FC<{
</div>
) : (
<Button className="w-fit" onClick={onEdit} size="sm">
{t("environments.product.languages.edit_languages")}
{t("environments.project.languages.edit_languages")}
</Button>
);
@@ -7,10 +7,10 @@ export function LanguageLabels() {
const t = useTranslations();
return (
<div className="mb-2 grid w-full grid-cols-4 gap-4">
<Label htmlFor="languagesId">{t("environments.product.languages.language")}</Label>
<Label htmlFor="languagesId">{t("environments.product.languages.identifier")}</Label>
<Label htmlFor="languagesId">{t("environments.project.languages.language")}</Label>
<Label htmlFor="languagesId">{t("environments.project.languages.identifier")}</Label>
<Label className="flex items-center space-x-2" htmlFor="Alias">
<span>{t("environments.product.languages.alias")}</span> <AliasTooltip t={t} />
<span>{t("environments.project.languages.alias")}</span> <AliasTooltip t={t} />
</Label>
</div>
);
@@ -25,7 +25,7 @@ function AliasTooltip({ t }: { t: (key: string) => string }) {
<InfoIcon className="h-4 w-4 text-slate-400" />
</div>
</TooltipTrigger>
<TooltipContent>{t("environments.product.languages.alias_tooltip")}</TooltipContent>
<TooltipContent>{t("environments.project.languages.alias_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
@@ -1,7 +1,7 @@
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { useTranslations } from "next-intl";
import type { TLanguage } from "@formbricks/types/product";
import type { TLanguage } from "@formbricks/types/project";
import { TUserLocale } from "@formbricks/types/user";
import { LanguageSelect } from "./language-select";
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from "react";
import type { TIso639Language } from "@formbricks/lib/i18n/utils";
import { iso639Languages } from "@formbricks/lib/i18n/utils";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
import type { TLanguage } from "@formbricks/types/product";
import type { TLanguage } from "@formbricks/types/project";
import { TUserLocale } from "@formbricks/types/user";
interface LanguageSelectProps {
@@ -70,7 +70,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
onChange={(e) => {
setSearchTerm(e.target.value);
}}
placeholder={t("environments.product.languages.search_items")}
placeholder={t("environments.project.languages.search_items")}
ref={inputRef}
type="text"
value={searchTerm}
@@ -2,7 +2,7 @@ import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslations } from "next-intl";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import type { TLanguage } from "@formbricks/types/product";
import type { TLanguage } from "@formbricks/types/project";
import type { TUserLocale } from "@formbricks/types/user";
interface LanguageToggleProps {
@@ -94,7 +94,7 @@ export function LocalizedEditor({
{value && selectedLanguageCode !== "default" && value.default ? (
<div className="mt-1 flex text-xs text-gray-500">
<strong>{t("environments.product.languages.translate")}:</strong>
<strong>{t("environments.project.languages.translate")}:</strong>
<label
className="fb-htmlbody ml-1" // styles are in global.css
dangerouslySetInnerHTML={{
@@ -110,7 +110,7 @@ export function LocalizedEditor({
{isInComplete ? (
<div className="mt-1 text-xs text-red-400">
{t("environments.product.languages.incomplete_translations")}
{t("environments.project.languages.incomplete_translations")}
</div>
) : null}
</div>
@@ -15,7 +15,7 @@ import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import type { TLanguage, TProduct } from "@formbricks/types/product";
import type { TLanguage, TProject } from "@formbricks/types/project";
import type { TSurvey, TSurveyLanguage, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { DefaultLanguageSelect } from "./default-language-select";
@@ -23,7 +23,7 @@ import { SecondaryLanguageSelect } from "./secondary-language-select";
interface MultiLanguageCardProps {
localSurvey: TSurvey;
product: TProduct;
project: TProject;
setLocalSurvey: (survey: TSurvey) => void;
activeQuestionId: TSurveyQuestionId | null;
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
@@ -44,7 +44,7 @@ export interface ConfirmationModalProps {
export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
activeQuestionId,
product,
project,
localSurvey,
setActiveQuestionId,
setLocalSurvey,
@@ -120,7 +120,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
};
const handleDefaultLanguageChange = (languageCode: string) => {
const language = product.languages.find((lang) => lang.code === languageCode);
const language = project.languages.find((lang) => lang.code === languageCode);
if (language) {
let languageExists = false;
@@ -213,7 +213,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<Switch
checked={isMultiLanguageActivated}
disabled={!isMultiLanguageAllowed || product.languages.length === 0}
disabled={!isMultiLanguageAllowed || project.languages.length === 0}
id="multi-lang-toggle"
onClick={() => {
handleActivationSwitchLogic();
@@ -238,16 +238,16 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
/>
) : (
<>
{product.languages.length <= 1 && (
{project.languages.length <= 1 && (
<div className="mb-4 text-sm italic text-slate-500">
{product.languages.length === 0
{project.languages.length === 0
? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")
: t(
"environments.surveys.edit.you_need_to_have_two_or_more_languages_set_up_in_your_product_to_work_with_translations"
"environments.surveys.edit.you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations"
)}
</div>
)}
{product.languages.length > 1 && (
{project.languages.length > 1 && (
<div className="my-4 space-y-4">
<div>
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
@@ -262,7 +262,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<DefaultLanguageSelect
defaultLanguage={defaultLanguage}
handleDefaultLanguageChange={handleDefaultLanguageChange}
product={product}
project={project}
setConfirmationModalInfo={setConfirmationModalInfo}
locale={locale}
/>
@@ -270,7 +270,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<SecondaryLanguageSelect
defaultLanguage={defaultLanguage}
localSurvey={localSurvey}
product={product}
project={project}
setActiveQuestionId={setActiveQuestionId}
setSelectedLanguageCode={setSelectedLanguageCode}
updateSurveyLanguages={updateSurveyLanguages}
@@ -282,7 +282,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
</div>
)}
<Link href={`/environments/${environmentId}/product/languages`} target="_blank">
<Link href={`/environments/${environmentId}/project/languages`} target="_blank">
<Button className="mt-2" size="sm" variant="secondary">
{t("environments.surveys.edit.manage_languages")}{" "}
<ArrowUpRight className="ml-2 h-4 w-4" />
@@ -1,11 +1,11 @@
import { useTranslations } from "next-intl";
import type { TLanguage, TProduct } from "@formbricks/types/product";
import type { TLanguage, TProject } from "@formbricks/types/project";
import type { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LanguageToggle } from "./language-toggle";
interface SecondaryLanguageSelectProps {
product: TProduct;
project: TProject;
defaultLanguage: TLanguage;
setSelectedLanguageCode: (languageCode: string) => void;
setActiveQuestionId: (questionId: TSurveyQuestionId) => void;
@@ -15,7 +15,7 @@ interface SecondaryLanguageSelectProps {
}
export function SecondaryLanguageSelect({
product,
project,
defaultLanguage,
setSelectedLanguageCode,
setActiveQuestionId,
@@ -35,7 +35,7 @@ export function SecondaryLanguageSelect({
<p className="text-sm">
{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}:
</p>
{product.languages
{project.languages
.filter((lang) => lang.id !== defaultLanguage.id)
.map((language) => (
<LanguageToggle
@@ -4,8 +4,8 @@ import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationIdFromLanguageId,
getOrganizationIdFromProductId,
getProductIdFromLanguageId,
getOrganizationIdFromProjectId,
getProjectIdFromLanguageId,
} from "@/lib/utils/helper";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
@@ -18,10 +18,10 @@ import {
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZLanguageInput } from "@formbricks/types/product";
import { ZLanguageInput } from "@formbricks/types/project";
const ZCreateLanguageAction = z.object({
productId: ZId,
projectId: ZId,
languageInput: ZLanguageInput,
});
@@ -42,7 +42,7 @@ export const checkMultiLanguagePermission = async (organizationId: string) => {
export const createLanguageAction = authenticatedActionClient
.schema(ZCreateLanguageAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -55,32 +55,32 @@ export const createLanguageAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: parsedInput.productId,
type: "projectTeam",
projectId: parsedInput.projectId,
minPermission: "manage",
},
],
});
await checkMultiLanguagePermission(organizationId);
return await createLanguage(parsedInput.productId, parsedInput.languageInput);
return await createLanguage(parsedInput.projectId, parsedInput.languageInput);
});
const ZDeleteLanguageAction = z.object({
languageId: ZId,
productId: ZId,
projectId: ZId,
});
export const deleteLanguageAction = authenticatedActionClient
.schema(ZDeleteLanguageAction)
.action(async ({ ctx, parsedInput }) => {
const languageProductId = await getProductIdFromLanguageId(parsedInput.languageId);
const languageProjectId = await getProjectIdFromLanguageId(parsedInput.languageId);
if (languageProductId !== parsedInput.productId) {
if (languageProjectId !== parsedInput.projectId) {
throw new Error("Invalid language id");
}
const organizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -91,15 +91,15 @@ export const deleteLanguageAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: parsedInput.productId,
type: "projectTeam",
projectId: parsedInput.projectId,
minPermission: "manage",
},
],
});
await checkMultiLanguagePermission(organizationId);
return await deleteLanguage(parsedInput.languageId, parsedInput.productId);
return await deleteLanguage(parsedInput.languageId, parsedInput.projectId);
});
const ZGetSurveysUsingGivenLanguageAction = z.object({
@@ -120,8 +120,8 @@ export const getSurveysUsingGivenLanguageAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: await getProductIdFromLanguageId(parsedInput.languageId),
type: "projectTeam",
projectId: await getProjectIdFromLanguageId(parsedInput.languageId),
minPermission: "manage",
},
],
@@ -132,7 +132,7 @@ export const getSurveysUsingGivenLanguageAction = authenticatedActionClient
});
const ZUpdateLanguageAction = z.object({
productId: ZId,
projectId: ZId,
languageId: ZId,
languageInput: ZLanguageInput,
});
@@ -140,13 +140,13 @@ const ZUpdateLanguageAction = z.object({
export const updateLanguageAction = authenticatedActionClient
.schema(ZUpdateLanguageAction)
.action(async ({ ctx, parsedInput }) => {
const languageProductId = await getProductIdFromLanguageId(parsedInput.languageId);
const languageProductId = await getProjectIdFromLanguageId(parsedInput.languageId);
if (languageProductId !== parsedInput.productId) {
if (languageProductId !== parsedInput.projectId) {
throw new Error("Invalid language id");
}
const organizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -159,13 +159,13 @@ export const updateLanguageAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
productId: parsedInput.productId,
type: "projectTeam",
projectId: parsedInput.projectId,
minPermission: "manage",
},
],
});
await checkMultiLanguagePermission(organizationId);
return await updateLanguage(parsedInput.productId, parsedInput.languageId, parsedInput.languageInput);
return await updateLanguage(parsedInput.projectId, parsedInput.languageId, parsedInput.languageInput);
});
@@ -4,7 +4,7 @@ import { teamCache } from "@/lib/cache/team";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { productCache } from "@formbricks/lib/product/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -74,7 +74,7 @@ export const updateMembership = async (
organizationId,
});
productCache.revalidate({
projectCache.revalidate({
userId,
});
+10 -10
View File
@@ -1,6 +1,6 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { TTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TTeamRole } from "@/modules/ee/teams/team-list/types/teams";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -11,16 +11,16 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
export const getProductPermissionByUserId = reactCache(
async (userId: string, productId: string): Promise<TTeamPermission | null> =>
export const getProjectPermissionByUserId = reactCache(
async (userId: string, projectId: string): Promise<TTeamPermission | null> =>
cache(
async () => {
validateInputs([userId, ZString], [productId, ZString]);
validateInputs([userId, ZString], [projectId, ZString]);
try {
const productMemberships = await prisma.productTeam.findMany({
const projectMemberships = await prisma.projectTeam.findMany({
where: {
productId,
projectId,
team: {
teamUsers: {
some: {
@@ -31,10 +31,10 @@ export const getProductPermissionByUserId = reactCache(
},
});
if (!productMemberships) return null;
if (!projectMemberships) return null;
let highestPermission: TTeamPermission | null = null;
for (const membership of productMemberships) {
for (const membership of projectMemberships) {
if (membership.permission === "manage") {
highestPermission = "manage";
} else if (membership.permission === "readWrite" && highestPermission !== "manage") {
@@ -58,9 +58,9 @@ export const getProductPermissionByUserId = reactCache(
throw new UnknownError("Error while fetching membership");
}
},
[`getProductPermissionByUserId-${userId}-${productId}`],
[`getProjectPermissionByUserId-${userId}-${projectId}`],
{
tags: [teamCache.tag.byUserId(userId), teamCache.tag.byProductId(productId)],
tags: [teamCache.tag.byUserId(userId), teamCache.tag.byProjectId(projectId)],
}
)()
);
@@ -2,35 +2,35 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProductId, getOrganizationIdFromTeamId } from "@/lib/utils/helper";
import { getOrganizationIdFromProjectId, getOrganizationIdFromTeamId } from "@/lib/utils/helper";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import {
addTeamAccess,
removeTeamAccess,
updateTeamAccessPermission,
} from "@/modules/ee/teams/product-teams/lib/teams";
} from "@/modules/ee/teams/project-teams/lib/teams";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZTeamPermission } from "./types/teams";
const ZRemoveAccessAction = z.object({
productId: z.string(),
projectId: z.string(),
teamId: z.string(),
});
export const removeAccessAction = authenticatedActionClient
.schema(ZRemoveAccessAction)
.action(async ({ ctx, parsedInput }) => {
const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
if (productOrganizationId !== teamOrganizationId) {
throw new Error("Team and product are not in the same organization");
if (projectOrganizationId !== teamOrganizationId) {
throw new Error("Team and project are not in the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: productOrganizationId,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
@@ -39,20 +39,20 @@ export const removeAccessAction = authenticatedActionClient
],
});
await checkRoleManagementPermission(productOrganizationId);
await checkRoleManagementPermission(projectOrganizationId);
return await removeTeamAccess(parsedInput.productId, parsedInput.teamId);
return await removeTeamAccess(parsedInput.projectId, parsedInput.teamId);
});
const ZAddAccessAction = z.object({
productId: z.string(),
projectId: z.string(),
teamIds: z.array(ZId),
});
export const addAccessAction = authenticatedActionClient
.schema(ZAddAccessAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -66,11 +66,11 @@ export const addAccessAction = authenticatedActionClient
await checkRoleManagementPermission(organizationId);
return await addTeamAccess(parsedInput.productId, parsedInput.teamIds);
return await addTeamAccess(parsedInput.projectId, parsedInput.teamIds);
});
const ZUpdateAccessPermissionAction = z.object({
productId: z.string(),
projectId: z.string(),
teamId: z.string(),
permission: ZTeamPermission,
});
@@ -78,16 +78,16 @@ const ZUpdateAccessPermissionAction = z.object({
export const updateAccessPermissionAction = authenticatedActionClient
.schema(ZUpdateAccessPermissionAction)
.action(async ({ ctx, parsedInput }) => {
const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
if (productOrganizationId !== teamOrganizationId) {
throw new Error("Team and product are not in the same organization");
if (projectOrganizationId !== teamOrganizationId) {
throw new Error("Team and project are not in the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: productOrganizationId,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
@@ -96,10 +96,10 @@ export const updateAccessPermissionAction = authenticatedActionClient
],
});
await checkRoleManagementPermission(productOrganizationId);
await checkRoleManagementPermission(projectOrganizationId);
return await updateTeamAccessPermission(
parsedInput.productId,
parsedInput.projectId,
parsedInput.teamId,
parsedInput.permission
);
@@ -1,8 +1,8 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { removeAccessAction, updateAccessPermissionAction } from "@/modules/ee/teams/product-teams/actions";
import { TProductTeam, TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
import { removeAccessAction, updateAccessPermissionAction } from "@/modules/ee/teams/project-teams/actions";
import { TProjectTeam, TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
@@ -21,13 +21,13 @@ import { useState } from "react";
import toast from "react-hot-toast";
interface AccessTableProps {
teams: TProductTeam[];
teams: TProjectTeam[];
environmentId: string;
productId: string;
projectId: string;
isOwnerOrManager: boolean;
}
export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager }: AccessTableProps) => {
export const AccessTable = ({ teams, environmentId, projectId, isOwnerOrManager }: AccessTableProps) => {
const t = useTranslations();
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [removeAccessModalOpen, setRemoveAccessModalOpen] = useState<boolean>(false);
@@ -35,7 +35,7 @@ export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager
const router = useRouter();
const removeAccess = async (teamId: string) => {
const removeAccessActionResponse = await removeAccessAction({ productId, teamId });
const removeAccessActionResponse = await removeAccessAction({ projectId, teamId });
if (removeAccessActionResponse?.data) {
router.refresh();
} else {
@@ -46,7 +46,7 @@ export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager
const handlePermissionChange = async (teamId: string, permission: TTeamPermission) => {
const updateAccessPermissionActionResponse = await updateAccessPermissionAction({
productId,
projectId,
teamId,
permission,
});
@@ -70,8 +70,8 @@ export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead>{t("environments.product.teams.team_name")}</TableHead>
<TableHead>{t("environments.product.teams.permission")}</TableHead>
<TableHead>{t("environments.project.teams.team_name")}</TableHead>
<TableHead>{t("environments.project.teams.permission")}</TableHead>
{isOwnerOrManager && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
@@ -79,7 +79,7 @@ export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager
{teams.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center">
{t("environments.product.teams.no_teams_found")}
{t("environments.project.teams.no_teams_found")}
</TableCell>
</TableRow>
)}
@@ -105,13 +105,13 @@ export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamPermission.Enum.read}>
{t("environments.product.teams.read")}
{t("environments.project.teams.read")}
</SelectItem>
<SelectItem value={ZTeamPermission.Enum.readWrite}>
{t("environments.product.teams.read_write")}
{t("environments.project.teams.read_write")}
</SelectItem>
<SelectItem value={ZTeamPermission.Enum.manage}>
{t("environments.product.teams.manage")}
{t("environments.project.teams.manage")}
</SelectItem>
</SelectContent>
</Select>
@@ -142,8 +142,8 @@ export const AccessTable = ({ teams, environmentId, productId, isOwnerOrManager
<AlertDialog
open={removeAccessModalOpen}
setOpen={setRemoveAccessModalOpen}
headerText={t("environments.product.teams.remove_access")}
mainText={t("environments.product.teams.remove_access_confirmation")}
headerText={t("environments.project.teams.remove_access")}
mainText={t("environments.project.teams.remove_access_confirmation")}
confirmBtnLabel={t("common.confirm")}
onDecline={() => {
setSelectedTeamId(null);
@@ -1,22 +1,22 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { AccessTable } from "@/modules/ee/teams/product-teams/components/access-table";
import { AddTeam } from "@/modules/ee/teams/product-teams/components/add-team";
import { TOrganizationTeam, TProductTeam } from "@/modules/ee/teams/product-teams/types/teams";
import { AccessTable } from "@/modules/ee/teams/project-teams/components/access-table";
import { AddTeam } from "@/modules/ee/teams/project-teams/components/add-team";
import { TOrganizationTeam, TProjectTeam } from "@/modules/ee/teams/project-teams/types/teams";
import { useTranslations } from "next-intl";
import { TProduct } from "@formbricks/types/product";
import { TProject } from "@formbricks/types/project";
interface AccessViewProps {
product: TProduct;
teams: TProductTeam[];
project: TProject;
teams: TProjectTeam[];
environmentId: string;
organizationTeams: TOrganizationTeam[];
isOwnerOrManager: boolean;
}
export const AccessView = ({
product,
project,
teams,
organizationTeams,
environmentId,
@@ -27,21 +27,21 @@ export const AccessView = ({
<>
<SettingsCard
title={t("common.teams")}
description={t("environments.product.teams.team_settings_description")}>
description={t("environments.project.teams.team_settings_description")}>
<div className="flex justify-end gap-2">
{isOwnerOrManager && (
<AddTeam
organizationTeams={organizationTeams}
productTeams={teams}
productId={product.id}
organizationId={product.organizationId}
projectTeams={teams}
projectId={project.id}
organizationId={project.organizationId}
/>
)}
</div>
<div className="mt-2">
<AccessTable
teams={teams}
productId={product.id}
projectId={project.id}
environmentId={environmentId}
isOwnerOrManager={isOwnerOrManager}
/>
@@ -1,7 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { addAccessAction } from "@/modules/ee/teams/product-teams/actions";
import { addAccessAction } from "@/modules/ee/teams/project-teams/actions";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
@@ -17,10 +17,10 @@ interface AddTeamModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamOptions: { label: string; value: string }[];
productId: string;
projectId: string;
}
export const AddTeamModal = ({ open, setOpen, teamOptions, productId }: AddTeamModalProps) => {
export const AddTeamModal = ({ open, setOpen, teamOptions, projectId }: AddTeamModalProps) => {
const t = useTranslations();
const [isLoading, setIsLoading] = useState(false);
const [selectedTeams, setSelectedTeams] = useState<string[]>([]);
@@ -31,7 +31,7 @@ export const AddTeamModal = ({ open, setOpen, teamOptions, productId }: AddTeamM
e.preventDefault();
setIsLoading(true);
const addTeamActionResponse = await addAccessAction({ productId, teamIds: selectedTeams });
const addTeamActionResponse = await addAccessAction({ projectId, teamIds: selectedTeams });
if (addTeamActionResponse?.data) {
router.refresh();
@@ -56,14 +56,14 @@ export const AddTeamModal = ({ open, setOpen, teamOptions, productId }: AddTeamM
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<UsersIcon className="h-5 w-5" />
<H4>{t("environments.product.teams.add_existing_team")}</H4>
<H4>{t("environments.project.teams.add_existing_team")}</H4>
</div>
</div>
</div>
<form onSubmit={handleAddTeam}>
<div className="overflow-visible p-6">
<Label htmlFor="team-name" className="mb-1 text-sm font-medium text-slate-900">
{t("environments.product.teams.select_teams")}
{t("environments.project.teams.select_teams")}
</Label>
<MultiSelect
value={selectedTeams}
@@ -1,8 +1,8 @@
"use client";
import { addAccessAction } from "@/modules/ee/teams/product-teams/actions";
import { AddTeamModal } from "@/modules/ee/teams/product-teams/components/add-team-modal";
import { TOrganizationTeam, TProductTeam } from "@/modules/ee/teams/product-teams/types/teams";
import { addAccessAction } from "@/modules/ee/teams/project-teams/actions";
import { AddTeamModal } from "@/modules/ee/teams/project-teams/components/add-team-modal";
import { TOrganizationTeam, TProjectTeam } from "@/modules/ee/teams/project-teams/types/teams";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { Button } from "@/modules/ui/components/button";
import { useTranslations } from "next-intl";
@@ -10,38 +10,38 @@ import { useState } from "react";
interface AddTeamProps {
organizationTeams: TOrganizationTeam[];
productTeams: TProductTeam[];
productId: string;
projectTeams: TProjectTeam[];
projectId: string;
organizationId: string;
}
export const AddTeam = ({ organizationTeams, productTeams, productId, organizationId }: AddTeamProps) => {
export const AddTeam = ({ organizationTeams, projectTeams, projectId, organizationId }: AddTeamProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState<boolean>(false);
const [addTeamModalOpen, setAddTeamModalOpen] = useState<boolean>(false);
const t = useTranslations();
const teams = organizationTeams
.filter((team) => !productTeams.find((productTeam) => productTeam.id === team.id))
.filter((team) => !projectTeams.find((projectTeam) => projectTeam.id === team.id))
.map((team) => ({ label: team.name, value: team.id }));
const onCreate = async (teamId: string) => {
await addAccessAction({ productId, teamIds: [teamId] });
await addAccessAction({ projectId: projectId, teamIds: [teamId] });
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setCreateTeamModalOpen(true)}>
{t("environments.product.teams.create_new_team")}
{t("environments.project.teams.create_new_team")}
</Button>
<Button variant="primary" size="sm" onClick={() => setAddTeamModalOpen(true)}>
{t("environments.product.teams.add_existing_team")}
{t("environments.project.teams.add_existing_team")}
</Button>
{addTeamModalOpen && (
<AddTeamModal
open={addTeamModalOpen}
setOpen={setAddTeamModalOpen}
teamOptions={teams}
productId={productId}
projectId={projectId}
/>
)}
@@ -2,50 +2,50 @@ import "server-only";
import { teamCache } from "@/lib/cache/team";
import {
TOrganizationTeam,
TProductTeam,
TProjectTeam,
TTeamPermission,
ZTeamPermission,
} from "@/modules/ee/teams/product-teams/types/teams";
} from "@/modules/ee/teams/project-teams/types/teams";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { productCache } from "@formbricks/lib/product/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getTeamsByProductId = reactCache(
async (productId: string): Promise<TProductTeam[] | null> =>
export const getTeamsByProjectId = reactCache(
async (projectId: string): Promise<TProjectTeam[] | null> =>
cache(
async () => {
validateInputs([productId, ZId]);
validateInputs([projectId, ZId]);
try {
const product = await prisma.product.findUnique({
const project = await prisma.project.findUnique({
where: {
id: productId,
id: projectId,
},
});
if (!product) {
throw new ResourceNotFoundError("Product", productId);
if (!project) {
throw new ResourceNotFoundError("Project", projectId);
}
const teams = await prisma.team.findMany({
where: {
productTeams: {
projectTeams: {
some: {
productId,
projectId: projectId,
},
},
},
select: {
id: true,
name: true,
productTeams: {
projectTeams: {
where: {
productId,
projectId: projectId,
},
select: {
permission: true,
@@ -59,14 +59,14 @@ export const getTeamsByProductId = reactCache(
},
});
const productTeams = teams.map((team) => ({
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
permission: team.productTeams[0].permission,
permission: team.projectTeams[0].permission,
memberCount: team._count.teamUsers,
}));
return productTeams;
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -75,40 +75,40 @@ export const getTeamsByProductId = reactCache(
throw error;
}
},
[`getTeamsByProductId-${productId}`],
[`getTeamsByProjectId-${projectId}`],
{
tags: [teamCache.tag.byProductId(productId), productCache.tag.byId(productId)],
tags: [teamCache.tag.byProjectId(projectId), projectCache.tag.byId(projectId)],
}
)()
);
export const removeTeamAccess = async (productId: string, teamId: string): Promise<boolean> => {
validateInputs([productId, ZId], [teamId, ZId]);
export const removeTeamAccess = async (projectId: string, teamId: string): Promise<boolean> => {
validateInputs([projectId, ZId], [teamId, ZId]);
try {
const productMembership = await prisma.productTeam.findFirst({
const projectMembership = await prisma.projectTeam.findFirst({
where: {
productId,
projectId: projectId,
teamId,
},
});
if (!productMembership) {
throw new AuthorizationError("Team does not have access to this product");
if (!projectMembership) {
throw new AuthorizationError("Team does not have access to this project");
}
await prisma.productTeam.deleteMany({
await prisma.projectTeam.deleteMany({
where: {
productId,
projectId: projectId,
teamId,
},
});
teamCache.revalidate({
id: teamId,
productId,
projectId: projectId,
});
productCache.revalidate({
id: productId,
projectCache.revalidate({
id: projectId,
});
return true;
@@ -121,24 +121,24 @@ export const removeTeamAccess = async (productId: string, teamId: string): Promi
}
};
export const addTeamAccess = async (productId: string, teamIds: string[]): Promise<boolean> => {
validateInputs([productId, ZId], [teamIds, z.array(ZId)]);
export const addTeamAccess = async (projectId: string, teamIds: string[]): Promise<boolean> => {
validateInputs([projectId, ZId], [teamIds, z.array(ZId)]);
try {
const product = await prisma.product.findUnique({
const project = await prisma.project.findUnique({
where: {
id: productId,
id: projectId,
},
});
if (!product) {
throw new ResourceNotFoundError("Product", productId);
if (!project) {
throw new ResourceNotFoundError("Project", projectId);
}
for (let teamId of teamIds) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
organizationId: product.organizationId,
organizationId: project.organizationId,
},
});
@@ -146,30 +146,30 @@ export const addTeamAccess = async (productId: string, teamIds: string[]): Promi
throw new ResourceNotFoundError("Team", teamId);
}
const productTeam = await prisma.productTeam.findFirst({
const projectTeam = await prisma.projectTeam.findFirst({
where: {
productId,
projectId,
teamId,
},
});
if (productTeam) {
throw new AuthorizationError("Teams already have access to this product");
if (projectTeam) {
throw new AuthorizationError("Teams already have access to this project");
}
await prisma.productTeam.create({
await prisma.projectTeam.create({
data: {
productId,
projectId,
teamId,
},
});
}
teamCache.revalidate({
productId,
projectId: projectId,
});
productCache.revalidate({
id: productId,
projectCache.revalidate({
id: projectId,
});
teamIds.forEach((teamId) => {
@@ -204,12 +204,12 @@ export const getTeamsByOrganizationId = reactCache(
},
});
const productTeams = teams.map((team) => ({
const projectTeams = teams.map((team) => ({
id: team.id,
name: team.name,
}));
return productTeams;
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -226,29 +226,29 @@ export const getTeamsByOrganizationId = reactCache(
);
export const updateTeamAccessPermission = async (
productId: string,
projectId: string,
teamId: string,
permission: TTeamPermission
): Promise<boolean> => {
validateInputs([productId, ZId], [teamId, ZId], [permission, ZTeamPermission]);
validateInputs([projectId, ZId], [teamId, ZId], [permission, ZTeamPermission]);
try {
const productMembership = await prisma.productTeam.findUniqueOrThrow({
const projectMembership = await prisma.projectTeam.findUniqueOrThrow({
where: {
productId_teamId: {
productId,
projectId_teamId: {
projectId,
teamId,
},
},
});
if (!productMembership) {
throw new AuthorizationError("Team does not have access to this product");
if (!projectMembership) {
throw new AuthorizationError("Team does not have access to this project");
}
await prisma.productTeam.update({
await prisma.projectTeam.update({
where: {
productId_teamId: {
productId,
projectId_teamId: {
projectId,
teamId,
},
},
@@ -259,11 +259,11 @@ export const updateTeamAccessPermission = async (
teamCache.revalidate({
id: teamId,
productId,
projectId: projectId,
});
productCache.revalidate({
id: productId,
projectCache.revalidate({
id: projectId,
});
return true;
@@ -1,10 +1,10 @@
import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { AccessView } from "@/modules/ee/teams/product-teams/components/access-view";
import { AccessView } from "@/modules/ee/teams/project-teams/components/access-view";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
@@ -12,20 +12,20 @@ import { getTranslations } from "next-intl/server";
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 { getTeamsByOrganizationId, getTeamsByProductId } from "./lib/teams";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getTeamsByOrganizationId, getTeamsByProjectId } from "./lib/teams";
export const ProductTeams = async (props: { params: Promise<{ environmentId: string }> }) => {
export const ProjectTeams = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslations();
const params = await props.params;
const [product, session, organization] = await Promise.all([
getProductByEnvironmentId(params.environmentId),
const [project, session, organization] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!product) {
throw new Error(t("common.product_not_found"));
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
@@ -40,7 +40,7 @@ export const ProductTeams = async (props: { params: Promise<{ environmentId: str
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
const teams = await getTeamsByProductId(product.id);
const teams = await getTeamsByProjectId(project.id);
if (!teams) {
throw new Error(t("common.teams_not_found"));
@@ -57,7 +57,7 @@ export const ProductTeams = async (props: { params: Promise<{ environmentId: str
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProductConfigNavigation
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="teams"
isMultiLanguageAllowed={isMultiLanguageAllowed}
@@ -68,7 +68,7 @@ export const ProductTeams = async (props: { params: Promise<{ environmentId: str
environmentId={params.environmentId}
organizationTeams={organizationTeams}
teams={teams}
product={product}
project={project}
isOwnerOrManager={isOwnerOrManager}
/>
</PageContentWrapper>
@@ -4,14 +4,14 @@ import { ZId } from "@formbricks/types/common";
export const ZTeamPermission = z.enum(["read", "readWrite", "manage"]);
export type TTeamPermission = z.infer<typeof ZTeamPermission>;
export const ZProductTeam = z.object({
export const ZProjectTeam = z.object({
id: ZId,
name: z.string(),
memberCount: z.number(),
permission: ZTeamPermission,
});
export type TProductTeam = z.infer<typeof ZProductTeam>;
export type TProjectTeam = z.infer<typeof ZProjectTeam>;
export const TOrganizationTeam = z.object({
id: ZId,
@@ -2,17 +2,17 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProductId, getOrganizationIdFromTeamId } from "@/lib/utils/helper";
import { getOrganizationIdFromProjectId, getOrganizationIdFromTeamId } from "@/lib/utils/helper";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import {
addTeamMembers,
addTeamProducts,
addTeamProjects,
deleteTeam,
removeTeamMember,
removeTeamProduct,
removeTeamProject,
updateTeamName,
updateTeamProductPermission,
updateTeamProjectPermission,
updateUserTeamRole,
} from "@/modules/ee/teams/team-details/lib/teams";
import { ZTeamRole } from "@/modules/ee/teams/team-list/types/teams";
@@ -175,25 +175,25 @@ export const addTeamMembersAction = authenticatedActionClient
return await addTeamMembers(parsedInput.teamId, parsedInput.userIds);
});
const ZUpdateTeamProductPermissionAction = z.object({
const ZUpdateTeamProjectPermissionAction = z.object({
teamId: ZId,
productId: ZId,
projectId: ZId,
permission: ZTeamPermission,
});
export const updateTeamProductPermissionAction = authenticatedActionClient
.schema(ZUpdateTeamProductPermissionAction)
export const updateTeamProjectPermissionAction = authenticatedActionClient
.schema(ZUpdateTeamProjectPermissionAction)
.action(async ({ ctx, parsedInput }) => {
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
if (teamOrganizationId !== productOrganizationId) {
throw new Error("Team and Product must belong to the same organization");
if (teamOrganizationId !== projectOrganizationId) {
throw new Error("Team and Project must belong to the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: productOrganizationId,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
@@ -202,33 +202,33 @@ export const updateTeamProductPermissionAction = authenticatedActionClient
],
});
await checkRoleManagementPermission(productOrganizationId);
await checkRoleManagementPermission(projectOrganizationId);
return await updateTeamProductPermission(
return await updateTeamProjectPermission(
parsedInput.teamId,
parsedInput.productId,
parsedInput.projectId,
parsedInput.permission
);
});
const ZRemoveTeamProductAction = z.object({
const ZRemoveTeamProjectAction = z.object({
teamId: ZId,
productId: ZId,
projectId: ZId,
});
export const removeTeamProductAction = authenticatedActionClient
.schema(ZRemoveTeamProductAction)
export const removeTeamProjectAction = authenticatedActionClient
.schema(ZRemoveTeamProjectAction)
.action(async ({ ctx, parsedInput }) => {
const teamOrganizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
const productOrganizationId = await getOrganizationIdFromProductId(parsedInput.productId);
const projectOrganizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
if (teamOrganizationId !== productOrganizationId) {
throw new Error("Team and Product must belong to the same organization");
if (teamOrganizationId !== projectOrganizationId) {
throw new Error("Team and Project must belong to the same organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: productOrganizationId,
organizationId: projectOrganizationId,
access: [
{
type: "organization",
@@ -237,18 +237,18 @@ export const removeTeamProductAction = authenticatedActionClient
],
});
await checkRoleManagementPermission(productOrganizationId);
await checkRoleManagementPermission(projectOrganizationId);
return await removeTeamProduct(parsedInput.teamId, parsedInput.productId);
return await removeTeamProject(parsedInput.teamId, parsedInput.projectId);
});
const ZAddTeamProductsAction = z.object({
const ZAddTeamProjectsAction = z.object({
teamId: ZId,
productIds: z.array(ZId),
projectIds: z.array(ZId),
});
export const addTeamProductsAction = authenticatedActionClient
.schema(ZAddTeamProductsAction)
export const addTeamProjectsAction = authenticatedActionClient
.schema(ZAddTeamProjectsAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId);
@@ -265,5 +265,5 @@ export const addTeamProductsAction = authenticatedActionClient
await checkRoleManagementPermission(organizationId);
return await addTeamProducts(parsedInput.teamId, parsedInput.productIds);
return await addTeamProjects(parsedInput.teamId, parsedInput.projectIds);
});
@@ -1,7 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { addTeamProductsAction } from "@/modules/ee/teams/team-details/actions";
import { addTeamProjectsAction } from "@/modules/ee/teams/team-details/actions";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
@@ -13,28 +13,28 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
interface AddTeamProductModalProps {
interface AddTeamProjectModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamId: string;
productOptions: { label: string; value: string }[];
projectOptions: { label: string; value: string }[];
}
export const AddTeamProductModal = ({ open, setOpen, teamId, productOptions }: AddTeamProductModalProps) => {
export const AddTeamProjectModal = ({ open, setOpen, teamId, projectOptions }: AddTeamProjectModalProps) => {
const t = useTranslations();
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleAddProducts = async (e) => {
const handleAddProjects = async (e) => {
e.preventDefault();
setIsLoading(true);
const addMembersActionResponse = await addTeamProductsAction({
const addMembersActionResponse = await addTeamProjectsAction({
teamId,
productIds: selectedProducts,
projectIds: selectedProjects,
});
if (addMembersActionResponse?.data) {
toast.success(t("environments.settings.teams.members_added_successfully"));
@@ -44,7 +44,7 @@ export const AddTeamProductModal = ({ open, setOpen, teamId, productOptions }: A
toast.error(errorMessage);
}
setSelectedProducts([]);
setSelectedProjects([]);
setIsLoading(false);
setOpen(false);
};
@@ -55,20 +55,20 @@ export const AddTeamProductModal = ({ open, setOpen, teamId, productOptions }: A
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<UserIcon className="h-5 w-5" />
<H4>{t("environments.settings.teams.add_products")}</H4>
<H4>{t("environments.settings.teams.add_projects")}</H4>
</div>
</div>
</div>
<form onSubmit={handleAddProducts}>
<form onSubmit={handleAddProjects}>
<div className="overflow-visible p-6">
<Label className="mb-1 text-sm font-medium text-slate-900">
{t("environments.settings.teams.organization_products")}
{t("environments.settings.teams.organization_projects")}
</Label>
<MultiSelect
value={selectedProducts}
options={productOptions}
value={selectedProjects}
options={projectOptions}
onChange={(value) => {
setSelectedProducts(value);
setSelectedProjects(value);
}}
/>
</div>
@@ -78,7 +78,7 @@ export const AddTeamProductModal = ({ open, setOpen, teamId, productOptions }: A
type="button"
onClick={() => {
setOpen(false);
setSelectedProducts([]);
setSelectedProjects([]);
}}>
Cancel
</Button>
@@ -1,13 +1,13 @@
"use client";
import { TeamMembers } from "@/modules/ee/teams/team-details/components/team-members";
import { TeamProducts } from "@/modules/ee/teams/team-details/components/team-products";
import { TeamProjects } from "@/modules/ee/teams/team-details/components/team-projects";
import { TeamSettings } from "@/modules/ee/teams/team-details/components/team-settings";
import {
TOrganizationMember,
TOrganizationProduct,
TOrganizationProject,
TTeam,
TTeamProduct,
TTeamProject,
} from "@/modules/ee/teams/team-details/types/teams";
import { TTeamRole } from "@/modules/ee/teams/team-list/types/teams";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
@@ -22,8 +22,8 @@ interface DetailsViewProps {
membershipRole?: TOrganizationRole;
organizationMembers: TOrganizationMember[];
teamRole: TTeamRole | null;
products: TTeamProduct[];
organizationProducts: TOrganizationProduct[];
projects: TTeamProject[];
organizationProjects: TOrganizationProject[];
}
export const DetailsView = ({
@@ -32,11 +32,11 @@ export const DetailsView = ({
membershipRole,
organizationMembers,
teamRole,
products,
organizationProducts,
projects,
organizationProjects,
}: DetailsViewProps) => {
const t = useTranslations();
const [activeId, setActiveId] = useState<"members" | "settings" | "products">("members");
const [activeId, setActiveId] = useState<"members" | "settings" | "projects">("members");
const navigation = [
{
@@ -45,9 +45,9 @@ export const DetailsView = ({
onClick: () => setActiveId("members"),
},
{
id: "products",
label: t("common.products"),
onClick: () => setActiveId("products"),
id: "projects",
label: t("common.projects"),
onClick: () => setActiveId("projects"),
},
{
id: "settings",
@@ -73,11 +73,11 @@ export const DetailsView = ({
teamRole={teamRole}
/>
)}
{activeId === "products" && (
<TeamProducts
organizationProducts={organizationProducts}
{activeId === "projects" && (
<TeamProjects
organizationProjects={organizationProjects}
membershipRole={membershipRole}
products={products}
projects={projects}
teamId={team.id}
/>
)}
@@ -1,10 +1,10 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
import { updateTeamProductPermissionAction } from "@/modules/ee/teams/team-details/actions";
import { AddTeamProductModal } from "@/modules/ee/teams/team-details/components/add-team-product-modal";
import { TOrganizationProduct, TTeamProduct } from "@/modules/ee/teams/team-details/types/teams";
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { updateTeamProjectPermissionAction } from "@/modules/ee/teams/team-details/actions";
import { AddTeamProjectModal } from "@/modules/ee/teams/team-details/components/add-team-project-modal";
import { TOrganizationProject, TTeamProject } from "@/modules/ee/teams/team-details/types/teams";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
@@ -23,52 +23,52 @@ import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { removeTeamProductAction } from "../actions";
import { removeTeamProjectAction } from "../actions";
interface TeamProductsProps {
interface TeamProjectsProps {
membershipRole?: TOrganizationRole;
products: TTeamProduct[];
projects: TTeamProject[];
teamId: string;
organizationProducts: TOrganizationProduct[];
organizationProjects: TOrganizationProject[];
}
export const TeamProducts = ({
export const TeamProjects = ({
membershipRole,
products,
projects,
teamId,
organizationProducts,
}: TeamProductsProps) => {
organizationProjects,
}: TeamProjectsProps) => {
const t = useTranslations();
const [openAddProductModal, setOpenAddProductModal] = useState<boolean>(false);
const [removeProductModalOpen, setRemoveProductModalOpen] = useState<boolean>(false);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [openAddProjectModal, setOpenAddProjectModal] = useState<boolean>(false);
const [removeProjectModalOpen, setRemoveProjectModalOpen] = useState<boolean>(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const handleRemoveProduct = async (productId: string) => {
const removeProductActionResponse = await removeTeamProductAction({
const handleRemoveProject = async (projectId: string) => {
const removeProjectActionResponse = await removeTeamProjectAction({
teamId,
productId,
projectId: projectId,
});
if (removeProductActionResponse?.data) {
toast.success(t("environments.settings.teams.product_removed_successfully"));
if (removeProjectActionResponse?.data) {
toast.success(t("environments.settings.teams.project_removed_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(removeProductActionResponse);
const errorMessage = getFormattedErrorMessage(removeProjectActionResponse);
toast.error(errorMessage);
}
setRemoveProductModalOpen(false);
setRemoveProjectModalOpen(false);
};
const handlePermissionChange = async (productId: string, permission: TTeamPermission) => {
const updateTeamPermissionResponse = await updateTeamProductPermissionAction({
const handlePermissionChange = async (projectId: string, permission: TTeamPermission) => {
const updateTeamPermissionResponse = await updateTeamProjectPermissionAction({
teamId,
productId,
projectId,
permission,
});
if (updateTeamPermissionResponse?.data) {
@@ -80,26 +80,26 @@ export const TeamProducts = ({
}
};
const productOptions = useMemo(
const projectOptions = useMemo(
() =>
organizationProducts
.filter((product) => !products.find((p) => p.id === product.id))
.map((product) => ({
label: product.name,
value: product.id,
organizationProjects
.filter((project) => !projects.find((p) => p.id === project.id))
.map((project) => ({
label: project.name,
value: project.id,
})),
[organizationProducts, products]
[organizationProjects, projects]
);
return (
<>
<Card className="mt-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t("environments.settings.teams.team_products")}</CardTitle>
<CardTitle>{t("environments.settings.teams.team_projects")}</CardTitle>
<div className="flex gap-2">
{isOwnerOrManager && (
<Button variant="primary" size="sm" onClick={() => setOpenAddProductModal(true)}>
{t("environments.settings.teams.add_product")}
<Button variant="primary" size="sm" onClick={() => setOpenAddProjectModal(true)}>
{t("environments.settings.teams.add_project")}
</Button>
)}
</div>
@@ -109,28 +109,28 @@ export const TeamProducts = ({
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
<TableHead>{t("environments.settings.teams.product_name")}</TableHead>
<TableHead>{t("environments.settings.teams.project_name")}</TableHead>
<TableHead>{t("environments.settings.teams.permission")}</TableHead>
{isOwnerOrManager && <TableHead>{t("common.actions")}</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 && (
{projects.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center">
{t("environments.settings.teams.empty_product_message")}
{t("environments.settings.teams.empty_project_message")}
</TableCell>
</TableRow>
)}
{products.map((product) => (
<TableRow key={product.id}>
<TableCell className="font-semibold">{product.name}</TableCell>
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-semibold">{project.name}</TableCell>
<TableCell>
{isOwnerOrManager ? (
<Select
value={product.permission}
value={project.permission}
onValueChange={(val: TTeamPermission) => {
handlePermissionChange(product.id, val);
handlePermissionChange(project.id, val);
}}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select type" className="text-sm" />
@@ -148,7 +148,7 @@ export const TeamProducts = ({
</SelectContent>
</Select>
) : (
<p>{TeamPermissionMapping[product.permission]}</p>
<p>{TeamPermissionMapping[project.permission]}</p>
)}
</TableCell>
{isOwnerOrManager && (
@@ -158,8 +158,8 @@ export const TeamProducts = ({
variant="warn"
size="sm"
onClick={() => {
setSelectedProductId(product.id);
setRemoveProductModalOpen(true);
setSelectedProjectId(project.id);
setRemoveProjectModalOpen(true);
}}>
{t("common.remove")}
</Button>
@@ -172,26 +172,26 @@ export const TeamProducts = ({
</div>
</CardContent>
</Card>
{openAddProductModal && (
<AddTeamProductModal
{openAddProjectModal && (
<AddTeamProjectModal
teamId={teamId}
open={openAddProductModal}
setOpen={setOpenAddProductModal}
productOptions={productOptions}
open={openAddProjectModal}
setOpen={setOpenAddProjectModal}
projectOptions={projectOptions}
/>
)}
{removeProductModalOpen && selectedProductId && (
{removeProjectModalOpen && selectedProjectId && (
<AlertDialog
open={removeProductModalOpen}
setOpen={setRemoveProductModalOpen}
headerText={t("environments.settings.teams.remove_product")}
mainText={t("environments.settings.teams.remove_product_confirmation")}
open={removeProjectModalOpen}
setOpen={setRemoveProjectModalOpen}
headerText={t("environments.settings.teams.remove_project")}
mainText={t("environments.settings.teams.remove_project_confirmation")}
confirmBtnLabel={t("common.confirm")}
onDecline={() => {
setSelectedProductId(null);
setRemoveProductModalOpen(false);
setSelectedProjectId(null);
setRemoveProjectModalOpen(false);
}}
onConfirm={() => handleRemoveProduct(selectedProductId)}
onConfirm={() => handleRemoveProject(selectedProjectId)}
/>
)}
</>
@@ -1,12 +1,12 @@
import "server-only";
import { membershipCache } from "@/lib/cache/membership";
import { teamCache } from "@/lib/cache/team";
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
import { TTeamPermission, ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import {
TOrganizationMember,
TOrganizationProduct,
TOrganizationProject,
TTeam,
TTeamProduct,
TTeamProject,
ZTeam,
} from "@/modules/ee/teams/team-details/types/teams";
import { TTeamRole, ZTeamRole } from "@/modules/ee/teams/team-list/types/teams";
@@ -17,7 +17,7 @@ import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { productCache } from "@formbricks/lib/product/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import {
@@ -117,9 +117,9 @@ export const updateTeamName = async (teamId: string, name: string): Promise<{ na
select: {
organizationId: true,
name: true,
productTeams: {
projectTeams: {
select: {
productId: true,
projectId: true,
},
},
},
@@ -127,8 +127,8 @@ export const updateTeamName = async (teamId: string, name: string): Promise<{ na
teamCache.revalidate({ id: teamId, organizationId: updatedTeam.organizationId });
for (const productTeam of updatedTeam.productTeams) {
teamCache.revalidate({ productId: productTeam.productId });
for (const projectTeam of updatedTeam.projectTeams) {
teamCache.revalidate({ projectId: projectTeam.projectId });
}
return { name: updatedTeam.name };
@@ -150,9 +150,9 @@ export const deleteTeam = async (teamId: string): Promise<boolean> => {
},
select: {
organizationId: true,
productTeams: {
projectTeams: {
select: {
productId: true,
projectId: true,
},
},
},
@@ -160,8 +160,8 @@ export const deleteTeam = async (teamId: string): Promise<boolean> => {
teamCache.revalidate({ id: teamId, organizationId: deletedTeam.organizationId });
for (const productTeam of deletedTeam.productTeams) {
teamCache.revalidate({ productId: productTeam.productId });
for (const projectTeam of deletedTeam.projectTeams) {
teamCache.revalidate({ projectId: projectTeam.projectId });
}
return true;
@@ -254,12 +254,12 @@ export const removeTeamMember = async (teamId: string, userId: string): Promise<
},
select: {
organizationId: true,
productTeams: {
projectTeams: {
where: {
teamId,
},
select: {
productId: true,
projectId: true,
},
},
},
@@ -297,10 +297,10 @@ export const removeTeamMember = async (teamId: string, userId: string): Promise<
organizationId: team.organizationId,
});
productCache.revalidate({ userId });
projectCache.revalidate({ userId });
for (const productTeam of team.productTeams) {
teamCache.revalidate({ productId: productTeam.productId });
for (const projectTeam of team.projectTeams) {
teamCache.revalidate({ projectId: projectTeam.projectId });
}
return true;
@@ -372,7 +372,7 @@ export const addTeamMembers = async (teamId: string, userIds: string[]): Promise
organizationId: true,
organization: {
select: {
products: {
projects: {
select: {
id: true,
},
@@ -420,14 +420,14 @@ export const addTeamMembers = async (teamId: string, userIds: string[]): Promise
});
teamCache.revalidate({ userId });
productCache.revalidate({ userId });
projectCache.revalidate({ userId });
}
for (const product of team.organization.products) {
teamCache.revalidate({ productId: product.id });
for (const project of team.organization.projects) {
teamCache.revalidate({ projectId: project.id });
}
productCache.revalidate({ organizationId: team.organizationId });
projectCache.revalidate({ organizationId: team.organizationId });
teamCache.revalidate({ id: teamId, organizationId: team.organizationId });
return true;
@@ -440,19 +440,19 @@ export const addTeamMembers = async (teamId: string, userIds: string[]): Promise
}
};
export const getTeamProducts = reactCache(
async (teamId: string): Promise<TTeamProduct[]> =>
export const getTeamProjects = reactCache(
async (teamId: string): Promise<TTeamProject[]> =>
cache(
async () => {
validateInputs([teamId, ZId]);
try {
const products = await prisma.productTeam.findMany({
const projects = await prisma.projectTeam.findMany({
where: {
teamId,
},
select: {
product: {
project: {
select: {
id: true,
name: true,
@@ -462,10 +462,10 @@ export const getTeamProducts = reactCache(
},
});
return products.map((product) => ({
id: product.product.id,
name: product.product.name,
permission: product.permission,
return projects.map((project) => ({
id: project.project.id,
name: project.project.name,
permission: project.permission,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -475,35 +475,35 @@ export const getTeamProducts = reactCache(
throw error;
}
},
[`getTeamProducts-${teamId}`],
[`getTeamProjects-${teamId}`],
{ tags: [teamCache.tag.byId(teamId)] }
)()
);
export const updateTeamProductPermission = async (
export const updateTeamProjectPermission = async (
teamId: string,
productId: string,
projectId: string,
permission: TTeamPermission
): Promise<boolean> => {
validateInputs([teamId, ZId], [productId, ZId], [permission, ZTeamPermission]);
validateInputs([teamId, ZId], [projectId, ZId], [permission, ZTeamPermission]);
try {
const productTeam = await prisma.productTeam.findUnique({
const projectTeam = await prisma.projectTeam.findUnique({
where: {
productId_teamId: {
productId,
projectId_teamId: {
projectId,
teamId,
},
},
});
if (!productTeam) {
throw new ResourceNotFoundError("productTeam", null);
if (!projectTeam) {
throw new ResourceNotFoundError("projectTeam", null);
}
await prisma.productTeam.update({
await prisma.projectTeam.update({
where: {
productId_teamId: {
productId,
projectId_teamId: {
projectId,
teamId,
},
},
@@ -512,8 +512,8 @@ export const updateTeamProductPermission = async (
},
});
teamCache.revalidate({ id: teamId, productId });
productCache.revalidate({ id: productId });
teamCache.revalidate({ id: teamId, projectId: projectId });
projectCache.revalidate({ id: projectId });
return true;
} catch (error) {
@@ -525,12 +525,12 @@ export const updateTeamProductPermission = async (
}
};
export const removeTeamProduct = async (teamId: string, productId: string): Promise<boolean> => {
validateInputs([teamId, ZId], [productId, ZId]);
export const removeTeamProject = async (teamId: string, projectId: string): Promise<boolean> => {
validateInputs([teamId, ZId], [projectId, ZId]);
try {
const product = await prisma.product.findUnique({
const project = await prisma.project.findUnique({
where: {
id: productId,
id: projectId,
},
select: {
id: true,
@@ -543,35 +543,35 @@ export const removeTeamProduct = async (teamId: string, productId: string): Prom
},
});
if (!product) {
throw new ResourceNotFoundError("product", productId);
if (!project) {
throw new ResourceNotFoundError("project", projectId);
}
const productTeam = await prisma.productTeam.findUnique({
const projectTeam = await prisma.projectTeam.findUnique({
where: {
productId_teamId: {
productId,
projectId_teamId: {
projectId,
teamId,
},
},
});
if (!productTeam) {
throw new ResourceNotFoundError("productTeam", null);
if (!projectTeam) {
throw new ResourceNotFoundError("projectTeam", null);
}
await prisma.productTeam.delete({
await prisma.projectTeam.delete({
where: {
productId_teamId: {
productId,
projectId_teamId: {
projectId,
teamId,
},
},
});
teamCache.revalidate({ id: teamId, productId });
productCache.revalidate({ id: productId, organizationId: product.organizationId });
teamCache.revalidate({ id: teamId, projectId: projectId });
projectCache.revalidate({ id: projectId, organizationId: project.organizationId });
for (const environment of product.environments) {
for (const environment of project.environments) {
organizationCache.revalidate({ environmentId: environment.id });
}
@@ -585,14 +585,14 @@ export const removeTeamProduct = async (teamId: string, productId: string): Prom
}
};
export const getProductsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationProduct[]> =>
export const getProjectsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationProject[]> =>
cache(
async () => {
validateInputs([organizationId, ZString]);
try {
const products = await prisma.product.findMany({
const projects = await prisma.project.findMany({
where: {
organizationId,
},
@@ -602,9 +602,9 @@ export const getProductsByOrganizationId = reactCache(
},
});
return products.map((product) => ({
id: product.id,
name: product.name,
return projects.map((project) => ({
id: project.id,
name: project.name,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -612,18 +612,18 @@ export const getProductsByOrganizationId = reactCache(
throw new DatabaseError(error.message);
}
throw new UnknownError("Error while fetching products");
throw new UnknownError("Error while fetching projects");
}
},
[`getProductsByOrganizationId-${organizationId}`],
[`getProjectsByOrganizationId-${organizationId}`],
{
tags: [productCache.tag.byOrganizationId(organizationId)],
tags: [projectCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const addTeamProducts = async (teamId: string, productIds: string[]): Promise<boolean> => {
validateInputs([teamId, ZId], [productIds, z.array(ZId)]);
export const addTeamProjects = async (teamId: string, projectIds: string[]): Promise<boolean> => {
validateInputs([teamId, ZId], [projectIds, z.array(ZId)]);
try {
const team = await prisma.team.findUnique({
where: {
@@ -638,10 +638,10 @@ export const addTeamProducts = async (teamId: string, productIds: string[]): Pro
throw new ResourceNotFoundError("team", teamId);
}
for (const productId of productIds) {
const product = await prisma.product.findUnique({
for (const projectId of projectIds) {
const project = await prisma.project.findUnique({
where: {
id: productId,
id: projectId,
organizationId: team.organizationId,
},
select: {
@@ -653,35 +653,35 @@ export const addTeamProducts = async (teamId: string, productIds: string[]): Pro
},
});
if (!product) {
throw new ResourceNotFoundError("product", productId);
if (!project) {
throw new ResourceNotFoundError("project", projectId);
}
const productTeam = await prisma.productTeam.findUnique({
const projectTeam = await prisma.projectTeam.findUnique({
where: {
productId_teamId: {
productId,
projectId_teamId: {
projectId,
teamId,
},
},
});
if (productTeam) {
if (projectTeam) {
continue;
}
await prisma.productTeam.create({
await prisma.projectTeam.create({
data: {
productId,
projectId,
teamId,
permission: "read",
},
});
teamCache.revalidate({ id: teamId, productId });
productCache.revalidate({ id: productId, organizationId: team.organizationId });
teamCache.revalidate({ id: teamId, projectId: projectId });
projectCache.revalidate({ id: projectId, organizationId: team.organizationId });
for (const environment of product.environments) {
for (const environment of project.environments) {
organizationCache.revalidate({ environmentId: environment.id });
}
}
@@ -5,9 +5,9 @@ import { DetailsView } from "@/modules/ee/teams/team-details/components/details-
import { TeamsNavigationBreadcrumbs } from "@/modules/ee/teams/team-details/components/team-navigation";
import {
getMembersByOrganizationId,
getProductsByOrganizationId,
getProjectsByOrganizationId,
getTeam,
getTeamProducts,
getTeamProjects,
} from "@/modules/ee/teams/team-details/lib/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { getServerSession } from "next-auth";
@@ -51,9 +51,9 @@ export const TeamDetails = async (props) => {
const organizationMembers = await getMembersByOrganizationId(organization.id);
const teamProducts = await getTeamProducts(params.teamId);
const teamProjects = await getTeamProjects(params.teamId);
const organizationProducts = await getProductsByOrganizationId(organization.id);
const organizationProjects = await getProjectsByOrganizationId(organization.id);
return (
<PageContentWrapper>
@@ -64,8 +64,8 @@ export const TeamDetails = async (props) => {
userId={userId}
membershipRole={currentUserMembership?.role}
teamRole={teamRole}
products={teamProducts}
organizationProducts={organizationProducts}
projects={teamProjects}
organizationProjects={organizationProjects}
/>
</PageContentWrapper>
);
@@ -1,4 +1,4 @@
import { ZTeamPermission } from "@/modules/ee/teams/product-teams/types/teams";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/teams";
import { ZTeamRole } from "@/modules/ee/teams/team-list/types/teams";
import { z } from "zod";
@@ -28,16 +28,16 @@ export const ZOrganizationMember = z.object({
});
export type TOrganizationMember = z.infer<typeof ZOrganizationMember>;
export const TTeamProduct = z.object({
export const TTeamProject = z.object({
id: z.string(),
name: z.string(),
permission: ZTeamPermission,
});
export type TTeamProduct = z.infer<typeof TTeamProduct>;
export type TTeamProject = z.infer<typeof TTeamProject>;
export const ZOrganizationProduct = z.object({
export const ZOrganizationProject = z.object({
id: z.string(),
name: z.string(),
});
export type TOrganizationProduct = z.infer<typeof ZOrganizationProduct>;
export type TOrganizationProject = z.infer<typeof ZOrganizationProject>;
+8 -8
View File
@@ -1,9 +1,9 @@
import { ProductTeamPermission, TeamUserRole } from "@prisma/client";
import { ProjectTeamPermission, TeamUserRole } from "@prisma/client";
export const TeamPermissionMapping = {
[ProductTeamPermission.read]: "Read",
[ProductTeamPermission.readWrite]: "Read & write",
[ProductTeamPermission.manage]: "Manage",
[ProjectTeamPermission.read]: "Read",
[ProjectTeamPermission.readWrite]: "Read & write",
[ProjectTeamPermission.manage]: "Manage",
};
export const TeamRoleMapping = {
@@ -21,10 +21,10 @@ export const getTeamAccessFlags = (role?: TeamUserRole | null) => {
};
};
export const getTeamPermissionFlags = (permissionLevel?: ProductTeamPermission | null) => {
const hasReadAccess = permissionLevel === ProductTeamPermission.read;
const hasReadWriteAccess = permissionLevel === ProductTeamPermission.readWrite;
const hasManageAccess = permissionLevel === ProductTeamPermission.manage;
export const getTeamPermissionFlags = (permissionLevel?: ProjectTeamPermission | null) => {
const hasReadAccess = permissionLevel === ProjectTeamPermission.read;
const hasReadWriteAccess = permissionLevel === ProjectTeamPermission.readWrite;
const hasManageAccess = permissionLevel === ProjectTeamPermission.manage;
return {
hasReadAccess,
@@ -19,7 +19,7 @@ export function CreateReminderNotificationBody({
<Container>
<Text>
{translateEmailText("weekly_summary_create_reminder_notification_body_text", locale, {
productName: notificationData.productName,
projectName: notificationData.projectName,
})}
</Text>
<Text className="pt-4 font-bold">
@@ -25,7 +25,7 @@ export function NoLiveSurveyNotificationEmail({
<NotificationHeader
endDate={endDate}
endYear={endYear}
productName={notificationData.productName}
projectName={notificationData.projectName}
startDate={startDate}
startYear={startYear}
locale={locale}
@@ -3,7 +3,7 @@ import React from "react";
import { translateEmailText } from "../../lib/utils";
interface NotificationHeaderProps {
productName: string;
projectName: string;
startDate: string;
endDate: string;
startYear: number;
@@ -12,7 +12,7 @@ interface NotificationHeaderProps {
}
export function NotificationHeader({
productName,
projectName,
startDate,
endDate,
startYear,
@@ -42,7 +42,7 @@ export function NotificationHeader({
</div>
<div className="float-right">
<Text className="m-0 text-right font-semibold">
{translateEmailText("notification_header_weekly_report_for", locale)} {productName}
{translateEmailText("notification_header_weekly_report_for", locale)} {projectName}
</Text>
{getNotificationHeaderimePeriod()}
</div>
@@ -28,7 +28,7 @@ export function WeeklySummaryNotificationEmail({
<NotificationHeader
endDate={endDate}
endYear={endYear}
productName={notificationData.productName}
projectName={notificationData.projectName}
startDate={startDate}
startYear={startYear}
locale={locale}
+4 -4
View File
@@ -43,8 +43,8 @@ interface SendEmailDataProps {
html: string;
}
const getEmailSubject = (productName: string): string => {
return `${productName} User Insights - Last Week by Formbricks`;
const getEmailSubject = (projectName: string): string => {
return `${projectName} User Insights - Last Week by Formbricks`;
};
export const sendEmail = async (emailData: SendEmailDataProps): Promise<void> => {
@@ -269,7 +269,7 @@ export const sendWeeklySummaryNotificationEmail = async (
);
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.productName),
subject: getEmailSubject(notificationData.projectName),
html,
});
};
@@ -301,7 +301,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
);
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.productName),
subject: getEmailSubject(notificationData.projectName),
html,
});
};
+58
View File
@@ -0,0 +1,58 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization } from "@formbricks/lib/organization/service";
import { updateUser } from "@formbricks/lib/user/service";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
const ZCreateOrganizationAction = z.object({
organizationName: z.string(),
});
export const createOrganizationAction = authenticatedActionClient
.schema(ZCreateOrganizationAction)
.action(async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled)
throw new OperationNotAllowedError(
"Creating Multiple organization is restricted on your instance of Formbricks"
);
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
const project = await createProject(newOrganization.id, {
name: "My Project",
});
const updatedNotificationSettings: TUserNotificationSettings = {
...ctx.user.notificationSettings,
alert: {
...ctx.user.notificationSettings?.alert,
},
weeklySummary: {
...ctx.user.notificationSettings?.weeklySummary,
[project.id]: true,
},
unsubscribedOrganizationIds: Array.from(
new Set([...(ctx.user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id])
),
};
await updateUser(ctx.user.id, {
notificationSettings: updatedNotificationSettings,
});
return newOrganization;
});
@@ -1,7 +1,7 @@
"use client";
import { createOrganizationAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createOrganizationAction } from "@/modules/organization/actions";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -0,0 +1,31 @@
"use client";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { EmptyContent, ModalButton } from "@/modules/ui/components/empty-content";
import { FolderIcon } from "lucide-react";
import { useTranslations } from "next-intl";
interface ProjectLimitModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
projectLimit: number;
buttons: [ModalButton, ModalButton];
}
export const ProjectLimitModal = ({ open, setOpen, projectLimit, buttons }: ProjectLimitModalProps) => {
const t = useTranslations();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-full max-w-[564px] bg-white">
<DialogTitle>{t("common.projects_limit_reached")}</DialogTitle>
<EmptyContent
icon={<FolderIcon className="h-6 w-6 text-slate-900" />}
title={t("common.unlock_more_projects_with_a_higher_plan")}
description={t("common.you_have_reached_your_limit_of_project_limit", { projectLimit })}
buttons={buttons}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,224 @@
"use client";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/empty-content";
import { BlendIcon, ChevronRightIcon, GlobeIcon, GlobeLockIcon, LinkIcon, PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
interface ProjectSwitcherProps {
isCollapsed: boolean;
isTextVisible: boolean;
project: TProject;
projects: TProject[];
organization: TOrganization;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
environmentId: string;
isOwnerOrManager: boolean;
}
export const ProjectSwitcher = ({
isCollapsed,
isTextVisible,
organization,
project,
projects,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
environmentId,
isOwnerOrManager,
}: ProjectSwitcherProps) => {
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const t = useTranslations();
const handleEnvironmentChangeByProject = (projectId: string) => {
router.push(`/projects/${projectId}/`);
};
const handleAddProject = (organizationId: string) => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
router.push(`/organizations/${organizationId}/projects/new/mode`);
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud && organization.billing.plan !== "enterprise") {
return [
{
text:
organization.billing.plan === "free"
? t("environments.settings.billing.start_free_trial")
: t("environments.settings.billing.upgrade"),
onClick: () => {
setOpenLimitModal(false);
router.push(`/environments/${environmentId}/settings/billing`);
},
},
{
text: t("common.learn_more"),
onClick: () => {
setOpenLimitModal(false);
router.push(`/environments/${environmentId}/settings/billing`);
},
},
];
} else {
if (isLicenseActive) {
return [
{
text: t("environments.settings.billing.get_in_touch"),
href: "https://cal.com/johannes/license",
onClick: () => setOpenLimitModal(false),
},
{
text: t("common.learn_more"),
href: "https://formbricks.com/docs/self-hosting/license",
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text:
organization.billing.plan === "free"
? t("environments.settings.billing.start_free_trial")
: t("environments.settings.billing.get_in_touch"),
href: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
onClick: () => setOpenLimitModal(false),
},
{
text: t("common.learn_more"),
href: "https://formbricks.com/docs/self-hosting/license",
onClick: () => setOpenLimitModal(false),
},
];
}
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="projectDropdownTrigger"
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">
{project.config.channel === "website" ? (
<GlobeIcon strokeWidth={1.5} />
) : project.config.channel === "app" ? (
<GlobeLockIcon strokeWidth={1.5} />
) : project.config.channel === "link" ? (
<LinkIcon strokeWidth={1.5} />
) : (
<BlendIcon strokeWidth={1.5} />
)}
</div>
{!isCollapsed && !isTextVisible && (
<>
<div>
<p
title={project.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"
)}>
{project.name}
</p>
<p
className={cn(
"text-sm text-slate-500 transition-opacity duration-200",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{project.config.channel === "link"
? t("common.link_and_email")
: capitalizeFirstLetter(project.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>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={-1}
align="end">
<DropdownMenuRadioGroup
value={project!.id}
onValueChange={(v) => handleEnvironmentChangeByProject(v)}>
{projects.map((project) => (
<DropdownMenuRadioItem value={project.id} className="cursor-pointer break-all" key={project.id}>
<div>
{project.config.channel === "website" ? (
<GlobeIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
) : project.config.channel === "app" ? (
<GlobeLockIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
) : project.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="">{project?.name}</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{isOwnerOrManager && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAddProject(organization.id)}
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
<span>{t("common.add_project")}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{openLimitModal && (
<ProjectLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
</>
);
};
@@ -0,0 +1,47 @@
"use client";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
export const AppConnectionLoading = () => {
const t = useTranslations();
const cards = [
{
title: t("environments.project.app-connection.app_connection"),
description: t("environments.project.app-connection.app_connection_description"),
skeletonLines: [{ classes: " h-44 max-w-full rounded-lg" }],
},
{
title: t("environments.project.app-connection.how_to_setup"),
description: t("environments.project.app-connection.how_to_setup_description"),
skeletonLines: [
{ classes: "h-12 w-24 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
{ classes: "h-12 w-24 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
],
},
{
title: t("environments.project.app-connection.environment_id"),
description: t("environments.project.app-connection.environment_id_description"),
skeletonLines: [{ classes: "h-12 w-4/6 rounded-lg" }],
},
];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="app-connection" loading />
</PageHeader>
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</PageContentWrapper>
);
};
@@ -0,0 +1,68 @@
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { EnvironmentIdField } from "@/modules/projects/settings/(setup)/components/environment-id-field";
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslations } from "next-intl/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
export const AppConnectionPage = async (props) => {
const params = await props.params;
const t = await getTranslations();
const [environment, organization] = await Promise.all([
getEnvironment(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="app-connection"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<div className="space-y-4">
<EnvironmentNotice environmentId={params.environmentId} subPageUrl="/project/app-connection" />
<SettingsCard
title={t("environments.project.app-connection.app_connection")}
description={t("environments.project.app-connection.app_connection_description")}>
{environment && <WidgetStatusIndicator environment={environment} />}
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.how_to_setup")}
description={t("environments.project.app-connection.how_to_setup_description")}
noPadding>
<SetupInstructions environmentId={params.environmentId} webAppUrl={WEBAPP_URL} />
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<EnvironmentIdField environmentId={params.environmentId} />
</SettingsCard>
</div>
</PageContentWrapper>
);
};
@@ -0,0 +1,11 @@
"use client";
import { CodeBlock } from "@/modules/ui/components/code-block";
export const EnvironmentIdField = ({ environmentId }: { environmentId: string }) => {
return (
<div className="prose prose-slate -mt-3">
<CodeBlock language="js">{environmentId}</CodeBlock>
</div>
);
};
@@ -0,0 +1,185 @@
"use client";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { useTranslations } from "next-intl";
import Link from "next/link";
import "prismjs/themes/prism.css";
import { useState } from "react";
const tabs = [
{
id: "npm",
label: "NPM",
icon: <NpmIcon />,
},
{ id: "html", label: "HTML", icon: <Html5Icon /> },
];
interface SetupInstructionsProps {
environmentId: string;
webAppUrl: string;
}
export const SetupInstructions = ({ environmentId, webAppUrl }: SetupInstructionsProps) => {
const t = useTranslations();
const [activeTab, setActiveTab] = useState(tabs[0].id);
return (
<div>
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} />
<div className="px-6 py-5">
{activeTab === "npm" ? (
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
<h4>{t("environments.project.app-connection.step_1")}</h4>
<CodeBlock language="sh">pnpm install @formbricks/js</CodeBlock>
<p>or</p>
<CodeBlock language="sh">npm install @formbricks/js</CodeBlock>
<p>or</p>
<CodeBlock language="sh">yarn add @formbricks/js</CodeBlock>
<h4>{t("environments.project.app-connection.step_2")}</h4>
<p>{t("environments.project.app-connection.step_2_description")}</p>
<CodeBlock language="js">{`import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
});
}`}</CodeBlock>
<ul className="list-disc text-sm">
<li>
<span className="font-semibold">environmentId :</span>{" "}
{t("environments.project.app-connection.environment_id_description_with_environment_id", {
environmentId: environmentId,
})}
</li>
<li>
<span className="font-semibold">apiHost:</span>{" "}
{t("environments.project.app-connection.api_host_description")}
</li>
</ul>
<span className="text-sm text-slate-600">
{t("environments.project.app-connection.if_you_are_planning_to")}
<Link
href="https://formbricks.com//docs/app-surveys/user-identification"
target="blank"
className="underline">
{t("environments.project.app-connection.identifying_your_users")}
</Link>{" "}
{t("environments.project.app-connection.you_also_need_to_pass_a")}{" "}
<span className="font-semibold">userId</span> {t("environments.project.app-connection.to_the")}{" "}
<span className="font-semibold">init</span> {t("environments.project.app-connection.function")}.
</span>
<h4>{t("environments.project.app-connection.step_3")}</h4>
<p>
{t("environments.project.app-connection.switch_on_the_debug_mode_by_appending")}{" "}
<i>?formbricksDebug=true</i>{" "}
{t("environments.project.app-connection.to_the_url_where_you_load_the")}{" "}
{t("environments.project.app-connection.formbricks_sdk")}.{" "}
{t("environments.project.app-connection.open_the_browser_console_to_see_the_logs")}{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/developer-docs/js-sdk#debug-mode"
target="_blank">
{t("common.read_docs")}
</Link>{" "}
</p>
<h4>{t("environments.project.app-connection.you_are_done")}</h4>
<p>{t("environments.project.app-connection.your_app_now_communicates_with_formbricks")}</p>
<ul className="list-disc text-sm text-slate-700">
<li>
<span>{t("environments.project.app-connection.need_a_more_detailed_setup_guide_for")}</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/website-surveys/quickstart"
target="_blank">
{t("environments.project.app-connection.check_out_the_docs")}
</Link>
</li>
<li>
<span>{t("environments.project.app-connection.not_working")}</span>{" "}
<Link
className="decoration-brand-dark"
target="_blank"
href="https://github.com/formbricks/formbricks/issues">
{t("environments.project.app-connection.open_an_issue_on_github")}
</Link>{" "}
</li>
<li>
<span>
{t("environments.project.app-connection.want_to_learn_how_to_add_user_attributes")}
</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/attributes/why"
target="_blank">
{t("environments.project.app-connection.dive_into_the_docs")}
</Link>
</li>
</ul>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
<h4>{t("environments.project.app-connection.step_1")}</h4>
<p>
{t("environments.project.app-connection.insert_this_code_into_the")} <code>{`<head>`}</code>{" "}
{t("environments.project.app-connection.tag_of_your_app")}
</p>
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="${webAppUrl}/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`}</CodeBlock>
<h4>Step 2: Debug mode</h4>
<p>
{t("environments.project.app-connection.switch_on_the_debug_mode_by_appending")}{" "}
<i>{`?formbricksDebug=true`}</i>{" "}
{t("environments.project.app-connection.to_the_url_where_you_load_the")}{" "}
{t("environments.project.app-connection.formbricks_sdk")}.{" "}
{t("environments.project.app-connection.open_the_browser_console_to_see_the_logs")}{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/developer-docs/js-sdk#debug-mode"
target="_blank">
{t("common.read_docs")}
</Link>{" "}
</p>
<h4>{t("environments.project.app-connection.you_are_done")}</h4>
<p>{t("environments.project.app-connection.your_app_now_communicates_with_formbricks")}</p>
<ul className="list-disc text-sm text-slate-700">
<li>
<span className="font-semibold">
{t("environments.project.app-connection.does_your_widget_work")}
</span>
<span>{t("environments.project.app-connection.scroll_to_the_top")}</span>
</li>
<li>
<span className="font-semibold">
{t("environments.project.app-connection.have_a_problem")}
</span>{" "}
<Link
className="decoration-brand-dark"
target="_blank"
href="https://github.com/formbricks/formbricks/issues">
{t("environments.project.app-connection.open_an_issue_on_github")}
</Link>{" "}
</li>
<li>
<span className="font-semibold">
{t("environments.project.app-connection.want_to_learn_how_to_add_user_attributes")}
</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/attributes/why"
target="_blank">
{t("environments.project.app-connection.dive_into_the_docs")}
</Link>
</li>
</ul>
</div>
) : null}
</div>
</div>
);
};
@@ -0,0 +1,71 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import {
getRemoveInAppBrandingPermission,
getRemoveLinkBrandingPermission,
} from "@/modules/ee/license-check/lib/utils";
import { updateProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
const ZUpdateProjectAction = z.object({
projectId: ZId,
data: ZProjectUpdateInput,
});
export const updateProjectAction = authenticatedActionClient
.schema(ZUpdateProjectAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
schema: ZProjectUpdateInput,
data: parsedInput.data,
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: parsedInput.projectId,
minPermission: "manage",
},
],
});
if (
parsedInput.data.inAppSurveyBranding !== undefined ||
parsedInput.data.linkSurveyBranding !== undefined
) {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
if (parsedInput.data.inAppSurveyBranding !== undefined) {
const canRemoveInAppBranding = getRemoveInAppBrandingPermission(organization);
if (!canRemoveInAppBranding) {
throw new OperationNotAllowedError("You are not allowed to remove in-app branding");
}
}
if (parsedInput.data.linkSurveyBranding !== undefined) {
const canRemoveLinkSurveyBranding = getRemoveLinkBrandingPermission(organization);
if (!canRemoveLinkSurveyBranding) {
throw new OperationNotAllowedError("You are not allowed to remove link survey branding");
}
}
}
return await updateProject(parsedInput.projectId, parsedInput.data);
});
@@ -0,0 +1,67 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationIdFromApiKeyId,
getOrganizationIdFromEnvironmentId,
getProjectIdFromApiKeyId,
getProjectIdFromEnvironmentId,
} from "@/lib/utils/helper";
import { createApiKey, deleteApiKey } from "@/modules/projects/settings/lib/api-key";
import { z } from "zod";
import { ZApiKeyCreateInput } from "@formbricks/types/api-keys";
import { ZId } from "@formbricks/types/common";
const ZDeleteApiKeyAction = z.object({
id: ZId,
});
export const deleteApiKeyAction = authenticatedActionClient
.schema(ZDeleteApiKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromApiKeyId(parsedInput.id),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "manage",
projectId: await getProjectIdFromApiKeyId(parsedInput.id),
},
],
});
return await deleteApiKey(parsedInput.id);
});
const ZCreateApiKeyAction = z.object({
environmentId: ZId,
apiKeyData: ZApiKeyCreateInput,
});
export const createApiKeyAction = authenticatedActionClient
.schema(ZCreateApiKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "manage",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await createApiKey(parsedInput.environmentId, parsedInput.apiKeyData);
});
@@ -0,0 +1,74 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { AlertTriangleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
interface MemberModalProps {
open: boolean;
setOpen: (v: boolean) => void;
onSubmit: (data: { label: string; environment: string }) => void;
}
export const AddApiKeyModal = ({ open, setOpen, onSubmit }: MemberModalProps) => {
const t = useTranslations();
const { register, getValues, handleSubmit, reset } = useForm<{ label: string; environment: string }>();
const submitAPIKey = async () => {
const data = getValues();
onSubmit(data);
setOpen(false);
reset();
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="text-xl font-medium text-slate-700">
{t("environments.project.api-keys.add_api_key")}
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitAPIKey)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<Label>{t("environments.project.api-keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div>
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
<p>{t("environments.project.api-keys.api_key_security_warning")}</p>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.project.api-keys.add_api_key")}</Button>
</div>
</div>
</form>
</div>
</Modal>
);
};
@@ -0,0 +1,45 @@
import { getTranslations } from "next-intl/server";
import { getApiKeys } from "@formbricks/lib/apiKey/service";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { TUserLocale } from "@formbricks/types/user";
import { EditAPIKeys } from "./edit-api-keys";
interface ApiKeyListProps {
environmentId: string;
environmentType: string;
locale: TUserLocale;
isReadOnly: boolean;
}
export const ApiKeyList = async ({ environmentId, environmentType, locale, isReadOnly }: ApiKeyListProps) => {
const t = await getTranslations();
const findEnvironmentByType = (environments, targetType) => {
for (const environment of environments) {
if (environment.type === targetType) {
return environment.id;
}
}
return null;
};
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const environments = await getEnvironments(project.id);
const environmentTypeId = findEnvironmentByType(environments, environmentType);
const apiKeys = await getApiKeys(environmentTypeId);
return (
<EditAPIKeys
environmentTypeId={environmentTypeId}
environmentType={environmentType}
apiKeys={apiKeys}
environmentId={environmentId}
locale={locale}
isReadOnly={isReadOnly}
/>
);
};
@@ -0,0 +1,168 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FilesIcon, TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { timeSince } from "@formbricks/lib/time";
import { TApiKey } from "@formbricks/types/api-keys";
import { TUserLocale } from "@formbricks/types/user";
import { createApiKeyAction, deleteApiKeyAction } from "../actions";
import { AddApiKeyModal } from "./add-api-key-modal";
interface EditAPIKeysProps {
environmentTypeId: string;
environmentType: string;
apiKeys: TApiKey[];
environmentId: string;
locale: TUserLocale;
isReadOnly: boolean;
}
export const EditAPIKeys = ({
environmentTypeId,
environmentType,
apiKeys,
environmentId,
locale,
isReadOnly,
}: EditAPIKeysProps) => {
const t = useTranslations();
const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false);
const [isDeleteKeyModalOpen, setOpenDeleteKeyModal] = useState(false);
const [apiKeysLocal, setApiKeysLocal] = useState<TApiKey[]>(apiKeys);
const [activeKey, setActiveKey] = useState({} as any);
const handleOpenDeleteKeyModal = (e, apiKey) => {
e.preventDefault();
setActiveKey(apiKey);
setOpenDeleteKeyModal(true);
};
const handleDeleteKey = async () => {
try {
await deleteApiKeyAction({ id: activeKey.id });
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
toast.success(t("environments.project.api-keys.api_key_deleted"));
} catch (e) {
toast.error(t("environments.project.api-keys.unable_to_delete_api_key"));
} finally {
setOpenDeleteKeyModal(false);
}
};
const handleAddAPIKey = async (data) => {
const createApiKeyResponse = await createApiKeyAction({
environmentId: environmentTypeId,
apiKeyData: { label: data.label },
});
console.log("createApiKeyResponse", createApiKeyResponse);
if (createApiKeyResponse?.data) {
const updatedApiKeys = [...apiKeysLocal!, createApiKeyResponse.data];
setApiKeysLocal(updatedApiKeys);
toast.success(t("environments.project.api-keys.api_key_created"));
} else {
const errorMessage = getFormattedErrorMessage(createApiKeyResponse);
toast.error(errorMessage);
}
setOpenAddAPIKeyModal(false);
};
const ApiKeyDisplay = ({ apiKey }) => {
const copyToClipboard = () => {
navigator.clipboard.writeText(apiKey);
toast.success(t("environments.project.api-keys.api_key_copied_to_clipboard"));
};
if (!apiKey) {
return <span className="italic">{t("environments.project.api-keys.secret")}</span>;
}
return (
<div className="flex items-center">
<span>{apiKey}</span>
<div className="copyApiKeyIcon">
<FilesIcon className="mx-2 h-4 w-4 cursor-pointer" onClick={copyToClipboard} />
</div>
</div>
);
};
return (
<div className="space-y-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
{t("environments.project.api-keys.api_key")}
</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div className="grid-cols-9">
{apiKeysLocal && apiKeysLocal.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("environments.project.api-keys.no_api_keys_yet")}
</div>
) : (
apiKeysLocal &&
apiKeysLocal.map((apiKey) => (
<div
className="grid h-12 w-full grid-cols-10 content-center items-center rounded-lg px-6 text-left text-sm text-slate-900"
key={apiKey.hashedKey}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.apiKey} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button
size="icon"
variant="minimal"
onClick={(e) => handleOpenDeleteKeyModal(e, apiKey)}
StartIcon={TrashIcon}
startIconClassName={cn("h-5 w-5 text-slate-700", isReadOnly && "opacity-50")}
/>
</div>
)}
</div>
))
)}
</div>
</div>
{!isReadOnly && (
<div>
<Button
size="sm"
disabled={environmentId !== environmentTypeId}
onClick={() => {
setOpenAddAPIKeyModal(true);
}}>
{t("environments.project.api-keys.add_env_api_key", { environmentType })}
</Button>
</div>
)}
<AddApiKeyModal
open={isAddAPIKeyModalOpen}
setOpen={setOpenAddAPIKeyModal}
onSubmit={handleAddAPIKey}
/>
<DeleteDialog
open={isDeleteKeyModalOpen}
setOpen={setOpenDeleteKeyModal}
deleteWhat={t("environments.project.api-keys.api_key")}
onDelete={handleDeleteKey}
/>
</div>
);
};
@@ -0,0 +1,52 @@
"use client";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
const LoadingCard = () => {
const t = useTranslations();
return (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6"></h3>
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500"></p>
</div>
<div className="w-full">
<div className="rounded-lg px-4 pt-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
{t("environments.project.api-keys.api_key")}
</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
</div>
<div className="px-6">
<div className="my-4 h-5 w-full animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="flex justify-start">
<div className="mt-4 flex h-8 w-44 animate-pulse flex-col items-center justify-center rounded-md bg-black text-sm text-white">
{t("common.loading")}
</div>
</div>
</div>
</div>
</div>
);
};
export const APIKeysLoading = () => {
const t = useTranslations();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="api-keys" loading />
</PageHeader>
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
<LoadingCard />
</PageContentWrapper>
);
};
@@ -0,0 +1,95 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
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 { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { ApiKeyList } from "./components/api-key-list";
export const APIKeysPage = async (props) => {
const params = await props.params;
const t = await getTranslations();
const [session, environment, organization, project] = await Promise.all([
getServerSession(authOptions),
getEnvironment(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
const locale = await findMatchingLocale();
if (!project) {
throw new Error(t("common.project_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="api-keys"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<EnvironmentNotice environmentId={environment.id} subPageUrl="/project/api-keys" />
{environment.type === "development" ? (
<SettingsCard
title={t("environments.project.api-keys.dev_api_keys")}
description={t("environments.project.api-keys.dev_api_keys_description")}>
<ApiKeyList
environmentId={params.environmentId}
environmentType="development"
locale={locale}
isReadOnly={isReadOnly}
/>
</SettingsCard>
) : (
<SettingsCard
title={t("environments.project.api-keys.prod_api_keys")}
description={t("environments.project.api-keys.prod_api_keys_description")}>
<ApiKeyList
environmentId={params.environmentId}
environmentType="production"
locale={locale}
isReadOnly={isReadOnly}
/>
</SettingsCard>
)}
</PageContentWrapper>
);
};
@@ -0,0 +1,80 @@
"use client";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { BrushIcon, KeyIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
interface ProjectConfigNavigationProps {
activeId: string;
environmentId?: string;
isMultiLanguageAllowed?: boolean;
loading?: boolean;
canDoRoleManagement?: boolean;
}
export const ProjectConfigNavigation = ({
activeId,
environmentId,
isMultiLanguageAllowed,
loading,
canDoRoleManagement,
}: ProjectConfigNavigationProps) => {
const t = useTranslations();
const pathname = usePathname();
let navigation = [
{
id: "general",
label: t("common.general"),
icon: <UsersIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/general`,
current: pathname?.includes("/general"),
},
{
id: "look",
label: t("common.look_and_feel"),
icon: <BrushIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/look`,
current: pathname?.includes("/look"),
},
{
id: "languages",
label: t("common.survey_languages"),
icon: <LanguagesIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/languages`,
hidden: !isMultiLanguageAllowed,
current: pathname?.includes("/languages"),
},
{
id: "tags",
label: t("common.tags"),
icon: <TagIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/tags`,
current: pathname?.includes("/tags"),
},
{
id: "api-keys",
label: t("common.api_keys"),
icon: <KeyIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/api-keys`,
current: pathname?.includes("/api-keys"),
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
icon: <ListChecksIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/app-connection`,
current: pathname?.includes("/app-connection"),
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${environmentId}/project/teams`,
hidden: !canDoRoleManagement,
current: pathname?.includes("/teams"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -0,0 +1,39 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { deleteProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
import { getUserProjects } from "@formbricks/lib/project/service";
import { ZId } from "@formbricks/types/common";
const ZProjectDeleteAction = z.object({
projectId: ZId,
});
export const deleteProjectAction = authenticatedActionClient
.schema(ZProjectDeleteAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const availableProjects = (await getUserProjects(ctx.user.id, organizationId)) ?? null;
if (!!availableProjects && availableProjects?.length <= 1) {
throw new Error("You can't delete the last project in the environment.");
}
// delete project
return await deleteProject(parsedInput.projectId);
});
@@ -0,0 +1,91 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteProjectAction } from "@/modules/projects/settings/general/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { truncate } from "@formbricks/lib/utils/strings";
import { TProject } from "@formbricks/types/project";
interface DeleteProjectRenderProps {
isDeleteDisabled: boolean;
isOwnerOrManager: boolean;
project: TProject;
}
export const DeleteProjectRender = ({
isDeleteDisabled,
isOwnerOrManager,
project,
}: DeleteProjectRenderProps) => {
const t = useTranslations();
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteProject = async () => {
setIsDeleting(true);
const deleteProjectResponse = await deleteProjectAction({ projectId: project.id });
if (deleteProjectResponse?.data) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
toast.success(t("environments.project.general.project_deleted_successfully"));
router.push("/");
} else {
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
toast.error(errorMessage);
setIsDeleteDialogOpen(false);
}
setIsDeleting(false);
};
return (
<div>
{!isDeleteDisabled && (
<div>
<p className="text-sm text-slate-900">
{t(
"environments.project.general.delete_project_name_includes_surveys_responses_people_and_more",
{
projectName: truncate(project.name, 30),
}
)}{" "}
<strong>{t("environments.project.general.this_action_cannot_be_undone")}</strong>
</p>
<Button
disabled={isDeleteDisabled}
variant="warn"
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
onClick={() => setIsDeleteDialogOpen(true)}>
{t("common.delete")}
</Button>
</div>
)}
{isDeleteDisabled && (
<Alert variant="warning">
<AlertDescription>
{!isOwnerOrManager
? t("environments.project.general.only_owners_or_managers_can_delete_projects")
: t("environments.project.general.cannot_delete_only_project")}
</AlertDescription>
</Alert>
)}
<DeleteDialog
deleteWhat="Project"
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteProject}
text={t("environments.project.general.delete_project_confirmation", {
projectName: truncate(project.name, 30),
})}
isDeleting={isDeleting}
/>
</div>
);
};
@@ -0,0 +1,37 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { DeleteProjectRender } from "@/modules/projects/settings/general/components/delete-project-render";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service";
import { TProject } from "@formbricks/types/project";
interface DeleteProjectProps {
environmentId: string;
project: TProject;
isOwnerOrManager: boolean;
}
export const DeleteProject = async ({ environmentId, project, isOwnerOrManager }: DeleteProjectProps) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const availableProjects = organization ? await getUserProjects(session.user.id, organization.id) : null;
const availableProjectsLength = availableProjects ? availableProjects.length : 0;
const isDeleteDisabled = availableProjectsLength <= 1 || !isOwnerOrManager;
return (
<DeleteProjectRender
isDeleteDisabled={isDeleteDisabled}
isOwnerOrManager={isOwnerOrManager}
project={project}
/>
);
};
@@ -0,0 +1,122 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TProject, ZProject } from "@formbricks/types/project";
interface EditProjectNameProps {
project: TProject;
isReadOnly: boolean;
}
const ZProjectNameInput = ZProject.pick({ name: true });
type TEditProjectName = z.infer<typeof ZProjectNameInput>;
export const EditProjectNameForm: React.FC<EditProjectNameProps> = ({ project, isReadOnly }) => {
const t = useTranslations();
const form = useForm<TEditProjectName>({
defaultValues: {
name: project.name,
},
resolver: zodResolver(ZProjectNameInput),
mode: "onChange",
});
const { errors, isDirty } = form.formState;
const nameError = errors.name?.message;
const isSubmitting = form.formState.isSubmitting;
const updateProject: SubmitHandler<TEditProjectName> = async (data) => {
const name = data.name.trim();
try {
if (nameError) {
toast.error(nameError);
return;
}
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
name,
},
});
if (updatedProjectResponse?.data) {
toast.success(t("environments.project.general.project_name_updated_successfully"));
form.resetField("name", { defaultValue: updatedProjectResponse.data.name });
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
} catch (err) {
console.error(err);
toast.error(t("environments.project.general.error_saving_project_information"));
}
};
return (
<>
<FormProvider {...form}>
<form className="w-full max-w-sm items-center space-y-2" onSubmit={form.handleSubmit(updateProject)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">
{t("environments.project.general.whats_your_project_called")}
</FormLabel>
<FormControl>
<Input
type="text"
id="name"
{...field}
placeholder={t("common.project_name")}
autoComplete="off"
required
isInvalid={!!nameError}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || isReadOnly}>
{t("common.update")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,113 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TProject, ZProject } from "@formbricks/types/project";
import { updateProjectAction } from "../../actions";
interface EditWaitingTimeProps {
project: TProject;
isReadOnly: boolean;
}
const ZProjectRecontactDaysInput = ZProject.pick({ recontactDays: true });
type TEditWaitingTimeFormValues = z.infer<typeof ZProjectRecontactDaysInput>;
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ project, isReadOnly }) => {
const t = useTranslations();
const form = useForm<TEditWaitingTimeFormValues>({
defaultValues: {
recontactDays: project.recontactDays,
},
resolver: zodResolver(ZProjectRecontactDaysInput),
mode: "onChange",
});
const { isDirty, isSubmitting } = form.formState;
const updateWaitingTime: SubmitHandler<TEditWaitingTimeFormValues> = async (data) => {
try {
const updatedProjectResponse = await updateProjectAction({ projectId: project.id, data });
if (updatedProjectResponse?.data) {
toast.success(t("environments.project.general.waiting_period_updated_successfully"));
form.resetField("recontactDays", { defaultValue: updatedProjectResponse.data.recontactDays });
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return (
<>
<FormProvider {...form}>
<form
className="flex w-full max-w-sm flex-col space-y-4"
onSubmit={form.handleSubmit(updateWaitingTime)}>
<FormField
control={form.control}
name="recontactDays"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="recontactDays">
{t("environments.project.general.wait_x_days_before_showing_next_survey")}
</FormLabel>
<FormControl>
<Input
type="number"
id="recontactDays"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange("");
}
field.onChange(parseInt(value, 10));
}}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
size="sm"
className="w-fit"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || isReadOnly}>
{t("common.update")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,39 @@
"use client";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
export const GeneralSettingsLoading = () => {
const t = useTranslations();
const cards = [
{
title: t("common.project_name"),
description: t("environments.project.general.project_name_settings_description"),
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
},
{
title: t("environments.project.general.recontact_waiting_time"),
description: t("environments.project.general.recontact_waiting_time_settings_description"),
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
},
{
title: t("environments.project.general.delete_project"),
description: t("environments.project.general.delete_project_settings_description"),
skeletonLines: [{ classes: "h-4 w-96" }, { classes: "h-8 w-24" }],
},
];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="general" loading />
</PageHeader>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</PageContentWrapper>
);
};
@@ -0,0 +1,94 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import packageJson from "@/package.json";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
export const GeneralSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslations();
const [project, session, organization] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { isMember, isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
const isOwnerOrManager = isOwner || isManager;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="general"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("common.project_name")}
description={t("environments.project.general.project_name_settings_description")}>
<EditProjectNameForm project={project} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.general.recontact_waiting_time")}
description={t("environments.project.general.recontact_waiting_time_settings_description")}>
<EditWaitingTimeForm project={project} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.general.delete_project")}
description={t("environments.project.general.delete_project_settings_description")}>
<DeleteProject
environmentId={params.environmentId}
project={project}
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
<div>
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
{!IS_FORMBRICKS_CLOUD && (
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
)}
</div>
</PageContentWrapper>
);
};
@@ -0,0 +1,48 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
export const metadata: Metadata = {
title: "Config",
};
export const ProjectSettingsLayout = async (props) => {
const params = await props.params;
const { children } = props;
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 currentUserMembership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error("Project not found");
}
return children;
};
@@ -0,0 +1,69 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { createHash, randomBytes } from "crypto";
import { prisma } from "@formbricks/database";
import { apiKeyCache } from "@formbricks/lib/apiKey/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TApiKey, TApiKeyCreateInput, ZApiKeyCreateInput } from "@formbricks/types/api-keys";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const deleteApiKey = async (id: string): Promise<TApiKey | null> => {
validateInputs([id, ZId]);
try {
const deletedApiKeyData = await prisma.apiKey.delete({
where: {
id: id,
},
});
apiKeyCache.revalidate({
id: deletedApiKeyData.id,
hashedKey: deletedApiKeyData.hashedKey,
environmentId: deletedApiKeyData.environmentId,
});
return deletedApiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
export const createApiKey = async (
environmentId: string,
apiKeyData: TApiKeyCreateInput
): Promise<TApiKey> => {
validateInputs([environmentId, ZId], [apiKeyData, ZApiKeyCreateInput]);
try {
const key = randomBytes(16).toString("hex");
const hashedKey = hashApiKey(key);
const result = await prisma.apiKey.create({
data: {
...apiKeyData,
hashedKey,
environment: { connect: { id: environmentId } },
},
});
apiKeyCache.revalidate({
id: result.id,
hashedKey: result.hashedKey,
environmentId: result.environmentId,
});
return { ...result, apiKey: key };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,219 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { isS3Configured } from "@formbricks/lib/constants";
import { environmentCache } from "@formbricks/lib/environment/cache";
import { createEnvironment } from "@formbricks/lib/environment/service";
import { projectCache } from "@formbricks/lib/project/cache";
import {
deleteLocalFilesByEnvironmentId,
deleteS3FilesByEnvironmentId,
} from "@formbricks/lib/storage/service";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { TProject, TProjectUpdateInput, ZProject, ZProjectUpdateInput } from "@formbricks/types/project";
const selectProject = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
organizationId: true,
languages: true,
recontactDays: true,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: true,
placement: true,
clickOutsideClose: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,
};
export const updateProject = async (
projectId: string,
inputProject: TProjectUpdateInput
): Promise<TProject> => {
validateInputs([projectId, ZId], [inputProject, ZProjectUpdateInput]);
const { environments, ...data } = inputProject;
let updatedProject;
try {
updatedProject = await prisma.project.update({
where: {
id: projectId,
},
data: {
...data,
environments: {
connect: environments?.map((environment) => ({ id: environment.id })) ?? [],
},
},
select: selectProject,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
try {
const project = ZProject.parse(updatedProject);
projectCache.revalidate({
id: project.id,
organizationId: project.organizationId,
});
project.environments.forEach((environment) => {
// revalidate environment cache
projectCache.revalidate({
environmentId: environment.id,
});
});
return project;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of project failed");
}
};
export const createProject = async (
organizationId: string,
projectInput: Partial<TProjectUpdateInput>
): Promise<TProject> => {
validateInputs([organizationId, ZString], [projectInput, ZProjectUpdateInput.partial()]);
if (!projectInput.name) {
throw new ValidationError("Project Name is required");
}
const { environments, teamIds, ...data } = projectInput;
try {
let project = await prisma.project.create({
data: {
config: {
channel: null,
industry: null,
},
...data,
name: projectInput.name,
organizationId,
},
select: selectProject,
});
if (teamIds) {
await prisma.projectTeam.createMany({
data: teamIds.map((teamId) => ({
projectId: project.id,
teamId,
})),
});
}
projectCache.revalidate({
id: project.id,
organizationId: project.organizationId,
});
const devEnvironment = await createEnvironment(project.id, {
type: "development",
});
const prodEnvironment = await createEnvironment(project.id, {
type: "production",
});
const updatedProject = await updateProject(project.id, {
environments: [devEnvironment, prodEnvironment],
});
return updatedProject;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new InvalidInputError("A project with this name already exists in your organization");
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
throw new InvalidInputError("A project with this name already exists in this organization");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteProject = async (projectId: string): Promise<TProject> => {
try {
const project = await prisma.project.delete({
where: {
id: projectId,
},
select: selectProject,
});
if (project) {
// delete all files from storage related to this project
if (isS3Configured()) {
const s3FilesPromises = project.environments.map(async (environment) => {
return deleteS3FilesByEnvironmentId(environment.id);
});
try {
await Promise.all(s3FilesPromises);
} catch (err) {
// fail silently because we don't want to throw an error if the files are not deleted
console.error(err);
}
} else {
const localFilesPromises = project.environments.map(async (environment) => {
return deleteLocalFilesByEnvironmentId(environment.id);
});
try {
await Promise.all(localFilesPromises);
} catch (err) {
// fail silently because we don't want to throw an error if the files are not deleted
console.error(err);
}
}
projectCache.revalidate({
id: project.id,
organizationId: project.organizationId,
});
environmentCache.revalidate({
projectId: project.id,
});
project.environments.forEach((environment) => {
// revalidate project cache
projectCache.revalidate({
environmentId: environment.id,
});
environmentCache.revalidate({
id: environment.id,
});
});
}
return project;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,180 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { tagCache } from "@formbricks/lib/tag/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { TTag } from "@formbricks/types/tags";
export const deleteTag = async (id: string): Promise<TTag> => {
validateInputs([id, ZId]);
try {
const tag = await prisma.tag.delete({
where: {
id,
},
});
tagCache.revalidate({
id,
environmentId: tag.environmentId,
});
return tag;
} catch (error) {
throw error;
}
};
export const updateTagName = async (id: string, name: string): Promise<TTag> => {
validateInputs([id, ZId], [name, ZString]);
try {
const tag = await prisma.tag.update({
where: {
id,
},
data: {
name,
},
});
tagCache.revalidate({
id: tag.id,
environmentId: tag.environmentId,
});
return tag;
} catch (error) {
throw error;
}
};
export const mergeTags = async (originalTagId: string, newTagId: string): Promise<TTag | undefined> => {
validateInputs([originalTagId, ZId], [newTagId, ZId]);
try {
let originalTag: TTag | null;
originalTag = await prisma.tag.findUnique({
where: {
id: originalTagId,
},
});
if (!originalTag) {
throw new Error("Tag not found");
}
let newTag: TTag | null;
newTag = await prisma.tag.findUnique({
where: {
id: newTagId,
},
});
if (!newTag) {
throw new Error("Tag not found");
}
// finds all the responses that have both the tags
let responsesWithBothTags = await prisma.response.findMany({
where: {
AND: [
{
tags: {
some: {
tagId: {
in: [originalTagId],
},
},
},
},
{
tags: {
some: {
tagId: {
in: [newTagId],
},
},
},
},
],
},
});
if (!!responsesWithBothTags?.length) {
await Promise.all(
responsesWithBothTags.map(async (response) => {
await prisma.$transaction([
prisma.tagsOnResponses.deleteMany({
where: {
responseId: response.id,
tagId: {
in: [originalTagId, newTagId],
},
},
}),
prisma.tagsOnResponses.create({
data: {
responseId: response.id,
tagId: newTagId,
},
}),
]);
})
);
await prisma.$transaction([
prisma.tagsOnResponses.updateMany({
where: {
tagId: originalTagId,
},
data: {
tagId: newTagId,
},
}),
prisma.tag.delete({
where: {
id: originalTagId,
},
}),
]);
return newTag;
}
await prisma.$transaction([
prisma.tagsOnResponses.updateMany({
where: {
tagId: originalTagId,
},
data: {
tagId: newTagId,
},
}),
prisma.tag.delete({
where: {
id: originalTagId,
},
}),
]);
tagCache.revalidate({
id: originalTagId,
environmentId: originalTag.environmentId,
});
tagCache.revalidate({
id: newTagId,
});
return newTag;
} catch (error) {
throw error;
}
};
@@ -0,0 +1,91 @@
"use client";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { UpgradePlanNotice } from "@/modules/ui/components/upgrade-plan-notice";
import { useTranslations } from "next-intl";
import { useState } from "react";
import toast from "react-hot-toast";
import { TProject, TProjectUpdateInput } from "@formbricks/types/project";
interface EditBrandingProps {
type: "linkSurvey" | "appSurvey";
project: TProject;
canRemoveBranding: boolean;
environmentId: string;
isReadOnly?: boolean;
}
export const EditBranding = ({
type,
project,
canRemoveBranding,
environmentId,
isReadOnly,
}: EditBrandingProps) => {
const t = useTranslations();
const [isBrandingEnabled, setIsBrandingEnabled] = useState(
type === "linkSurvey" ? project.linkSurveyBranding : project.inAppSurveyBranding
);
const [updatingBranding, setUpdatingBranding] = useState(false);
const toggleBranding = async () => {
try {
setUpdatingBranding(true);
const newBrandingState = !isBrandingEnabled;
setIsBrandingEnabled(newBrandingState);
let inputProject: Partial<TProjectUpdateInput> = {
[type === "linkSurvey" ? "linkSurveyBranding" : "inAppSurveyBranding"]: newBrandingState,
};
await updateProjectAction({ projectId: project.id, data: inputProject });
toast.success(
newBrandingState
? t("environments.project.look.formbricks_branding_shown")
: t("environments.project.look.formbricks_branding_hidden")
);
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingBranding(false);
}
};
return (
<div className="w-full items-center space-y-4">
<div className="flex items-center space-x-2">
<Switch
id={`branding-${type}`}
checked={isBrandingEnabled}
onCheckedChange={toggleBranding}
disabled={!canRemoveBranding || updatingBranding || isReadOnly}
/>
<Label htmlFor={`branding-${type}`}>
{t("environments.project.look.show_formbricks_branding_in", {
type: type === "linkSurvey" ? t("common.link") : t("common.app"),
})}
</Label>
</div>
{!canRemoveBranding && (
<div>
{type === "linkSurvey" && (
<div className="mb-8">
<UpgradePlanNotice
message={t("environments.project.look.formbricks_branding_upgrade_message")}
textForUrl={t("environments.project.look.formbricks_branding_upgrade_text")}
url={`/environments/${environmentId}/settings/billing`}
/>
</div>
)}
{type !== "linkSurvey" && (
<UpgradePlanNotice
message={t("environments.project.look.formbricks_branding_upgrade_message_in_app")}
textForUrl={t("environments.project.look.formbricks_branding_upgrade_text")}
url={`/environments/${environmentId}/settings/billing`}
/>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,217 @@
"use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { ChangeEvent, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TProject, TProjectUpdateInput } from "@formbricks/types/project";
interface EditLogoProps {
project: TProject;
environmentId: string;
isReadOnly: boolean;
}
export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps) => {
const t = useTranslations();
const [logoUrl, setLogoUrl] = useState<string | undefined>(project.logo?.url || undefined);
const [logoBgColor, setLogoBgColor] = useState<string | undefined>(project.logo?.bgColor || undefined);
const [isBgColorEnabled, setIsBgColorEnabled] = useState<boolean>(!!project.logo?.bgColor);
const [confirmRemoveLogoModalOpen, setConfirmRemoveLogoModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageUpload = async (file: File) => {
setIsLoading(true);
try {
const uploadResult = await handleFileUpload(file, environmentId);
if (uploadResult.error) {
toast.error(uploadResult.error);
return;
}
setLogoUrl(uploadResult.url);
} catch (error) {
toast.error(t("environments.project.look.logo_upload_failed"));
} finally {
setIsLoading(false);
}
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) await handleImageUpload(file);
setIsEditing(true);
};
const saveChanges = async () => {
if (!isEditing) {
setIsEditing(true);
return;
}
setIsLoading(true);
try {
const updatedProject: TProjectUpdateInput = {
logo: { url: logoUrl, bgColor: isBgColorEnabled ? logoBgColor : undefined },
};
const updateProjectResponse = await updateProjectAction({
projectId: project.id,
data: updatedProject,
});
if (updateProjectResponse?.data) {
toast.success(t("environments.project.look.logo_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updateProjectResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("environments.project.look.failed_to_update_logo"));
} finally {
setIsEditing(false);
setIsLoading(false);
}
};
const removeLogo = async () => {
setLogoUrl(undefined);
if (!isEditing) {
setIsEditing(true);
return;
}
setIsLoading(true);
try {
const updatedProject: TProjectUpdateInput = {
logo: { url: undefined, bgColor: undefined },
};
const updateProjectResponse = await updateProjectAction({
projectId: project.id,
data: updatedProject,
});
if (updateProjectResponse?.data) {
toast.success(t("environments.project.look.logo_removed_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updateProjectResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("environments.project.look.failed_to_remove_logo"));
} finally {
setIsEditing(false);
setIsLoading(false);
setConfirmRemoveLogoModalOpen(false);
}
};
const toggleBackgroundColor = (enabled: boolean) => {
setIsBgColorEnabled(enabled);
if (!enabled) {
setLogoBgColor(undefined);
} else if (!logoBgColor) {
setLogoBgColor("#f8f8f8");
}
};
return (
<>
<div className="w-full space-y-8" id="edit-logo">
{logoUrl ? (
<Image
src={logoUrl}
alt="Logo"
width={256}
height={56}
style={{ backgroundColor: logoBgColor || undefined }}
className="-mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
) : (
<FileInput
id="logo-input"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={(files: string[]) => {
setLogoUrl(files[0]);
setIsEditing(true);
}}
disabled={isReadOnly}
/>
)}
<Input
ref={fileInputRef}
type="file"
accept="image/jpeg, image/png, image/webp"
className="hidden"
disabled={isReadOnly}
onChange={handleFileChange}
/>
{isEditing && logoUrl && (
<>
<div className="flex gap-2">
<Button onClick={() => fileInputRef.current?.click()} variant="secondary" size="sm">
{t("environments.project.look.replace_logo")}
</Button>
<Button
variant="warn"
size="sm"
onClick={() => setConfirmRemoveLogoModalOpen(true)}
disabled={!isEditing}>
{t("environments.project.look.remove_logo")}
</Button>
</div>
<AdvancedOptionToggle
isChecked={isBgColorEnabled}
onToggle={toggleBackgroundColor}
htmlId="addBackgroundColor"
title={t("environments.project.look.add_background_color")}
description={t("environments.project.look.add_background_color_description")}
childBorder
customContainerClass="p-0"
disabled={!isEditing}>
{isBgColorEnabled && (
<div className="px-2">
<ColorPicker
color={logoBgColor || "#f8f8f8"}
onChange={setLogoBgColor}
disabled={!isEditing}
/>
</div>
)}
</AdvancedOptionToggle>
</>
)}
{logoUrl && (
<Button onClick={saveChanges} disabled={isLoading || isReadOnly} size="sm">
{isEditing ? t("common.save") : t("common.edit")}
</Button>
)}
<DeleteDialog
open={confirmRemoveLogoModalOpen}
setOpen={setConfirmRemoveLogoModalOpen}
deleteWhat={t("common.logo")}
text={t("environments.project.look.remove_logo_confirmation")}
onDelete={removeLogo}
/>
</div>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,223 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@/modules/ui/components/form";
import { Label } from "@/modules/ui/components/label";
import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { cn } from "@formbricks/lib/cn";
import { TProject } from "@formbricks/types/project";
const placements = [
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
{ name: "common.top_right", value: "topRight", disabled: false },
{ name: "common.top_left", value: "topLeft", disabled: false },
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
{ name: "common.centered_modal", value: "center", disabled: false },
];
interface EditPlacementProps {
project: TProject;
environmentId: string;
isReadOnly: boolean;
}
const ZProjectPlacementInput = z.object({
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
darkOverlay: z.boolean(),
clickOutsideClose: z.boolean(),
});
type EditPlacementFormValues = z.infer<typeof ZProjectPlacementInput>;
export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) => {
const t = useTranslations();
const form = useForm<EditPlacementFormValues>({
defaultValues: {
placement: project.placement,
darkOverlay: project.darkOverlay ?? false,
clickOutsideClose: project.clickOutsideClose ?? false,
},
resolver: zodResolver(ZProjectPlacementInput),
});
const currentPlacement = form.watch("placement");
const darkOverlay = form.watch("darkOverlay");
const clickOutsideClose = form.watch("clickOutsideClose");
const isSubmitting = form.formState.isSubmitting;
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-slate-700/80" : "bg-slate-200";
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
placement: data.placement,
darkOverlay: data.darkOverlay,
clickOutsideClose: data.clickOutsideClose,
},
});
if (updatedProjectResponse?.data) {
toast.success(t("environments.project.look.placement_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
};
return (
<>
<FormProvider {...form}>
<form className="w-full items-center" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex">
<FormField
control={form.control}
name="placement"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
onValueChange={(value) => {
field.onChange(value);
}}
disabled={isReadOnly}
className="h-full">
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem
id={placement.value}
value={placement.value}
disabled={placement.disabled}
checked={field.value === placement.value}
/>
<Label
htmlFor={placement.value}
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t(placement.name)}
</Label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<div
className={cn(
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",
overlayStyle
)}>
<div
className={cn(
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
getPlacementStyle(currentPlacement)
)}></div>
</div>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="darkOverlay"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("environments.project.look.centered_modal_overlay_color")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
field.onChange(value === "darkOverlay");
}}
disabled={isReadOnly}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
<Label
htmlFor="lightOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.light_overlay")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
<Label
htmlFor="darkOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.dark_overlay")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="clickOutsideClose"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</FormLabel>
<FormControl>
<RadioGroup
disabled={isReadOnly}
onValueChange={(value) => {
field.onChange(value === "allow");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
<Label
htmlFor="disallow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" checked={field.value} />
<Label
htmlFor="allow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
<Button className="mt-4 w-fit" size="sm" loading={isSubmitting} disabled={isReadOnly}>
{t("common.save")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,216 @@
"use client";
import { ClientLogo } from "@/modules/ui/components/client-logo";
import { MediaBackground } from "@/modules/ui/components/media-background";
import { Modal } from "@/modules/ui/components/preview-survey/components/modal";
import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button";
import { SurveyInline } from "@/modules/ui/components/survey";
import { Variants, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { Fragment, useRef, useState } from "react";
import type { TProject } from "@formbricks/types/project";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
interface ThemeStylingPreviewSurveyProps {
survey: TSurvey;
project: TProject;
previewType: TSurveyType;
setPreviewType: (type: TSurveyType) => void;
}
const previewParentContainerVariant: Variants = {
expanded: {
position: "fixed",
height: "100%",
width: "100%",
backgroundColor: "rgba(0, 0, 0, 0.4)",
backdropFilter: "blur(15px)",
left: 0,
top: 0,
zIndex: 1040,
transition: {
ease: "easeIn",
duration: 0.001,
},
},
shrink: {
display: "none",
position: "fixed",
backgroundColor: "rgba(0, 0, 0, 0.0)",
backdropFilter: "blur(0px)",
transition: {
duration: 0,
},
zIndex: -1,
},
};
export const ThemeStylingPreviewSurvey = ({
survey,
project,
previewType,
setPreviewType,
}: ThemeStylingPreviewSurveyProps) => {
const [isFullScreenPreview] = useState(false);
const [previewPosition] = useState("relative");
const ContentRef = useRef<HTMLDivElement | null>(null);
const [shrink] = useState(false);
const t = useTranslations();
const { projectOverwrites } = survey || {};
const previewScreenVariants: Variants = {
expanded: {
right: "5%",
bottom: "10%",
top: "12%",
width: "40%",
position: "fixed",
height: "80%",
zIndex: 1050,
boxShadow: "0px 4px 5px 4px rgba(169, 169, 169, 0.25)",
transition: {
ease: "easeInOut",
duration: shrink ? 0.3 : 0,
},
},
expanded_with_fixed_positioning: {
zIndex: 1050,
position: "fixed",
top: "5%",
right: "5%",
bottom: "10%",
width: "90%",
height: "90%",
transition: {
ease: "easeOut",
duration: 0.4,
},
},
shrink: {
display: "relative",
width: ["83.33%"],
height: ["95%"],
},
};
const { placement: surveyPlacement } = projectOverwrites || {};
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
const placement = surveyPlacement || project.placement;
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const highlightBorderColor = project.styling.highlightBorderColor?.light;
const [surveyFormKey, setSurveyFormKey] = useState<number>(Date.now());
const resetQuestionProgress = () => {
setSurveyFormKey(Date.now());
};
const isAppSurvey = previewType === "app";
const scrollToEditLogoSection = () => {
const editLogoSection = document.getElementById("edit-logo");
if (editLogoSection) {
editLogoSection.scrollIntoView({ behavior: "smooth" });
}
};
return (
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
layout
variants={previewScreenVariants}
animate={
isFullScreenPreview
? previewPosition === "relative"
? "expanded"
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{isAppSurvey ? "Your web app" : "Preview"}</p>
<div className="flex items-center">
<ResetProgressButton onClick={resetQuestionProgress} />
</div>
</div>
</div>
{isAppSurvey ? (
<Modal
isOpen
placement={placement}
clickOutsideClose={clickOutsideClose}
darkOverlay={darkOverlay}
previewMode="desktop"
background={project.styling.cardBackgroundColor?.light}
borderRadius={project.styling.roundness ?? 8}>
<Fragment key={surveyFormKey}>
<SurveyInline
survey={{ ...survey, type: "app" }}
isBrandingEnabled={project.inAppSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
styling={project.styling}
isCardBorderVisible={!highlightBorderColor}
languageCode="default"
/>
</Fragment>
</Modal>
) : (
<MediaBackground survey={survey} project={project} ContentRef={ContentRef} isEditorView>
{!project.styling?.isLogoHidden && (
<div className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
<ClientLogo project={project} previewSurvey />
</div>
)}
<div
key={surveyFormKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
responseCount={42}
styling={project.styling}
languageCode="default"
/>
</div>
</MediaBackground>
)}
</div>
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<div
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("link")}>
{t("common.link_survey")}
</div>
<div
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("app")}>
{t("common.app_survey")}
</div>
</div>
</div>
);
};
@@ -0,0 +1,284 @@
"use client";
import { BackgroundStylingCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard";
import { CardStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings";
import { FormStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { ThemeStylingPreviewSurvey } from "@/modules/projects/settings/look/components/theme-styling-preview-survey";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { RotateCcwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { SubmitHandler, UseFormReturn, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { COLOR_DEFAULTS, getPreviewSurvey } from "@formbricks/lib/styling/constants";
import { TProject, TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
interface ThemeStylingProps {
project: TProject;
environmentId: string;
colors: string[];
isUnsplashConfigured: boolean;
locale: string;
isReadOnly: boolean;
}
export const ThemeStyling = ({
project,
environmentId,
colors,
isUnsplashConfigured,
locale,
isReadOnly,
}: ThemeStylingProps) => {
const t = useTranslations();
const router = useRouter();
const form = useForm<TProjectStyling>({
defaultValues: {
...project.styling,
// specify the default values for the colors
allowStyleOverwrite: project.styling.allowStyleOverwrite ?? true,
brandColor: { light: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor },
questionColor: { light: project.styling.questionColor?.light ?? COLOR_DEFAULTS.questionColor },
inputColor: { light: project.styling.inputColor?.light ?? COLOR_DEFAULTS.inputColor },
inputBorderColor: { light: project.styling.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor },
cardBackgroundColor: {
light: project.styling.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: { light: project.styling.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor },
cardShadowColor: { light: project.styling.cardShadowColor?.light ?? COLOR_DEFAULTS.cardShadowColor },
highlightBorderColor: project.styling.highlightBorderColor?.light
? {
light: project.styling.highlightBorderColor.light,
}
: undefined,
isDarkModeEnabled: project.styling.isDarkModeEnabled ?? false,
roundness: project.styling.roundness ?? 8,
cardArrangement: project.styling.cardArrangement ?? {
linkSurveys: "straight",
appSurveys: "straight",
},
background: project.styling.background,
hideProgressBar: project.styling.hideProgressBar ?? false,
isLogoHidden: project.styling.isLogoHidden ?? false,
},
resolver: zodResolver(ZProjectStyling),
});
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
const [backgroundStylingOpen, setBackgroundStylingOpen] = useState(false);
const onReset = useCallback(async () => {
const defaultStyling: TProjectStyling = {
allowStyleOverwrite: true,
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
isLogoHidden: false,
highlightBorderColor: undefined,
isDarkModeEnabled: false,
background: {
bg: "#fff",
bgType: "color",
},
roundness: 8,
cardArrangement: {
linkSurveys: "straight",
appSurveys: "straight",
},
};
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
styling: { ...defaultStyling },
},
});
if (updatedProjectResponse?.data) {
form.reset({ ...defaultStyling });
toast.success(t("environments.project.look.styling_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
}, [form, project.id, router]);
const onSubmit: SubmitHandler<TProjectStyling> = async (data) => {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
styling: data,
},
});
if (updatedProjectResponse?.data) {
form.reset({ ...updatedProjectResponse.data.styling });
toast.success(t("environments.project.look.styling_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
};
if (isReadOnly) {
return (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
);
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex">
{/* Styling settings */}
<div className="relative flex w-1/2 flex-col pr-6">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="allowStyleOverwrite"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<div>
<FormLabel>{t("environments.project.look.enable_custom_styling")}</FormLabel>
<FormDescription>
{t("environments.project.look.enable_custom_styling_description")}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg bg-slate-50 p-4">
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
isSettingsPage
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
isSettingsPage
project={project}
surveyType={previewSurveyType}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
<BackgroundStylingCard
open={backgroundStylingOpen}
setOpen={setBackgroundStylingOpen}
environmentId={environmentId}
colors={colors}
key={form.watch("background.bg")}
isSettingsPage
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<Button size="sm" type="submit">
{t("common.save")}
</Button>
<Button
type="button"
size="sm"
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
{t("common.reset_to_default")}
<RotateCcwIcon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Survey Preview */}
<div className="relative w-1/2 rounded-lg bg-slate-100 pt-4">
<div className="sticky top-4 mb-4 h-[600px]">
<ThemeStylingPreviewSurvey
survey={getPreviewSurvey(locale) as TSurvey}
project={{
...project,
styling: form.getValues(),
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
/>
</div>
</div>
{/* Confirm reset styling modal */}
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText={t("environments.project.look.reset_styling")}
mainText={t("environments.project.look.reset_styling_confirmation")}
confirmBtnLabel={t("common.confirm")}
onConfirm={() => {
onReset();
setConfirmResetStylingModalOpen(false);
}}
onDecline={() => setConfirmResetStylingModalOpen(false)}
/>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,170 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslations } from "next-intl";
import { cn } from "@formbricks/lib/cn";
const placements = [
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
{ name: "common.top_right", value: "topRight", disabled: false },
{ name: "common.top_left", value: "topLeft", disabled: false },
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
{ name: "common.centered_modal", value: "center", disabled: false },
];
export const ProjectLookSettingsLoading = () => {
const t = useTranslations();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="look" loading />
</PageHeader>
<SettingsCard
title={t("environments.project.look.theme")}
className="max-w-7xl"
description={t("environments.project.look.theme_settings_description")}>
<div className="flex animate-pulse">
<div className="w-1/2">
<div className="flex flex-col gap-4 pr-6">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<Switch />
<div className="flex flex-col">
<h3 className="text-sm font-semibold text-slate-700">
{t("environments.project.look.enable_custom_styling")}
</h3>
<p className="text-xs text-slate-500">
{t("environments.project.look.enable_custom_styling_description")}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 bg-slate-50 p-4">
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.form_styling")}
</h2>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.style_the_question_texts_descriptions_and_input_fields")}
</p>
</div>
</div>
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.card_styling")}
</h2>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.style_the_survey_card")}
</p>
</div>
</div>
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.background_styling")}
</h2>
<Badge text={t("common.link_surveys")} type="gray" size="normal" />
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="relative flex w-1/2 flex-row items-center justify-center rounded-lg bg-slate-100 pt-4">
<div className="relative mb-3 flex h-fit w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-[90%] max-h-[90%] w-4/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{t("common.preview")}</p>
<div className="flex items-center pr-6">{t("common.restart")}</div>
</div>
</div>
<div className="grid h-[500px] place-items-center bg-white">
<h1 className="text-xl font-semibold text-slate-700">{t("common.loading")}</h1>
</div>
</div>
</div>
</div>
</div>
</SettingsCard>
<SettingsCard title="Logo" description="Upload your company logo to brand surveys and link previews.">
<div className="w-full animate-pulse items-center">
<div className="relative flex h-52 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800">
<p className="text-xl font-semibold text-slate-700">{t("common.loading")}</p>
</div>
</div>
</SettingsCard>
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<div className="w-full items-center">
<div className="flex cursor-not-allowed select-none">
<RadioGroup>
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem
className="cursor-not-allowed select-none"
id={placement.value}
value={placement.value}
disabled={placement.disabled}
/>
<Label
htmlFor={placement.value}
className={cn(
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
)}>
{t(placement.name)}
</Label>
</div>
))}
</RadioGroup>
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
</div>
</div>
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("common.loading")}
</Button>
</div>
</SettingsCard>
<SettingsCard
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">
<div className="w-full items-center">
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
<Switch id="signature" checked={false} />
<Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label>
</div>
</div>
</SettingsCard>
</PageContentWrapper>
);
};
@@ -0,0 +1,125 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRemoveInAppBrandingPermission,
getRemoveLinkBrandingPermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EditLogo } from "@/modules/projects/settings/look/components/edit-logo";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { cn } from "@formbricks/lib/cn";
import { DEFAULT_LOCALE, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUserLocale } from "@formbricks/lib/user/service";
import { EditBranding } from "./components/edit-branding";
import { EditPlacementForm } from "./components/edit-placement-form";
import { ThemeStyling } from "./components/theme-styling";
export const ProjectLookSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslations();
const [session, organization, project] = await Promise.all([
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const locale = session?.user.id ? await getUserLocale(session.user.id) : undefined;
const canRemoveInAppBranding = getRemoveInAppBrandingPermission(organization);
const canRemoveLinkBranding = getRemoveLinkBrandingPermission(organization);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="look"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("environments.project.look.theme")}
className={cn(!isReadOnly && "max-w-7xl")}
description={t("environments.project.look.theme_settings_description")}>
<ThemeStyling
environmentId={params.environmentId}
project={project}
colors={SURVEY_BG_COLORS}
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
locale={locale ?? DEFAULT_LOCALE}
isReadOnly={isReadOnly}
/>
</SettingsCard>
<SettingsCard
title={t("common.logo")}
description={t("environments.project.look.logo_settings_description")}>
<EditLogo project={project} environmentId={params.environmentId} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.look.app_survey_placement")}
description={t("environments.project.look.app_survey_placement_settings_description")}>
<EditPlacementForm project={project} environmentId={params.environmentId} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.look.formbricks_branding")}
description={t("environments.project.look.formbricks_branding_settings_description")}>
<div className="space-y-4">
<EditBranding
type="linkSurvey"
project={project}
canRemoveBranding={canRemoveLinkBranding}
environmentId={params.environmentId}
isReadOnly={isReadOnly}
/>
<EditBranding
type="appSurvey"
project={project}
canRemoveBranding={canRemoveInAppBranding}
environmentId={params.environmentId}
isReadOnly={isReadOnly}
/>
</div>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</SettingsCard>
</PageContentWrapper>
);
};
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export const ProjectSettingsPage = async (props) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/project/general`);
};
@@ -0,0 +1,101 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromTagId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromTagId,
getProjectIdFromEnvironmentId,
getProjectIdFromTagId,
} from "@/lib/utils/helper";
import { deleteTag, mergeTags, updateTagName } from "@/modules/projects/settings/lib/tag";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
const ZDeleteTagAction = z.object({
tagId: ZId,
});
export const deleteTagAction = authenticatedActionClient
.schema(ZDeleteTagAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromTagId(parsedInput.tagId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromTagId(parsedInput.tagId),
},
],
});
return await deleteTag(parsedInput.tagId);
});
const ZUpdateTagNameAction = z.object({
tagId: ZId,
name: z.string(),
});
export const updateTagNameAction = authenticatedActionClient
.schema(ZUpdateTagNameAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromTagId(parsedInput.tagId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromTagId(parsedInput.tagId),
},
],
});
return await updateTagName(parsedInput.tagId, parsedInput.name);
});
const ZMergeTagsAction = z.object({
originalTagId: ZId,
newTagId: ZId,
});
export const mergeTagsAction = authenticatedActionClient
.schema(ZMergeTagsAction)
.action(async ({ ctx, parsedInput }) => {
const originalTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.originalTagId);
const newTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.newTagId);
if (originalTagEnvironmentId !== newTagEnvironmentId) {
throw new Error("Tags must be in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(newTagEnvironmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(newTagEnvironmentId),
},
],
});
return await mergeTags(parsedInput.originalTagId, parsedInput.newTagId);
});
@@ -0,0 +1,46 @@
"use client";
import { SingleTag } from "@/modules/projects/settings/tags/components/single-tag";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { useTranslations } from "next-intl";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TTag, TTagsCount } from "@formbricks/types/tags";
interface EditTagsWrapperProps {
environment: TEnvironment;
environmentTags: TTag[];
environmentTagsCount: TTagsCount;
isReadOnly: boolean;
}
export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
const t = useTranslations();
const { environment, environmentTags, environmentTagsCount, isReadOnly } = props;
return (
<div className="">
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("environments.project.tags.tag")}</div>
<div className="col-span-1 text-center">{t("environments.project.tags.count")}</div>
{!isReadOnly && (
<div className="col-span-1 flex justify-center text-center">{t("common.actions")}</div>
)}
</div>
{!environmentTags?.length ? (
<EmptySpaceFiller environment={environment} type="tag" noWidgetRequired />
) : null}
{environmentTags?.map((tag) => (
<SingleTag
key={tag.id}
tagId={tag.id}
tagName={tag.name}
tagCount={environmentTagsCount?.find((count) => count.tagId === tag.id)?.count ?? 0}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
/>
))}
</div>
);
};
@@ -0,0 +1,73 @@
import { Button } from "@/modules/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useTranslations } from "next-intl";
import { useState } from "react";
interface MergeTagsComboboxProps {
tags: Tag[];
onSelect: (tagId: string) => void;
}
type Tag = {
label: string;
value: string;
};
export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) => {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
className="font-medium text-slate-900 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent">
{t("environments.project.tags.merge")}
</Button>
</PopoverTrigger>
<PopoverContent className="max-h-60 w-[200px] overflow-y-auto p-0">
<Command>
<div className="p-1">
<CommandInput
placeholder={t("environments.project.tags.search_tags")}
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
</div>
<CommandList>
<CommandEmpty>
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
</CommandEmpty>
<CommandGroup>
{tags?.length === 0 ? (
<CommandItem>{t("environments.project.tags.no_tag_found")}</CommandItem>
) : null}
{tags?.map((tag) => (
<CommandItem
key={tag.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
onSelect(tag.value);
}}>
{tag.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,160 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
deleteTagAction,
mergeTagsAction,
updateTagNameAction,
} from "@/modules/projects/settings/tags/actions";
import { MergeTagsCombobox } from "@/modules/projects/settings/tags/components/merge-tags-combobox";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { AlertCircleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TTag } from "@formbricks/types/tags";
interface SingleTagProps {
tagId: string;
tagName: string;
tagCount?: number;
tagCountLoading?: boolean;
updateTagsCount?: () => void;
environmentTags: TTag[];
isReadOnly?: boolean;
}
export const SingleTag: React.FC<SingleTagProps> = ({
tagId,
tagName,
tagCount = 0,
tagCountLoading = false,
updateTagsCount = () => {},
environmentTags,
isReadOnly = false,
}) => {
const t = useTranslations();
const router = useRouter();
const [updateTagError, setUpdateTagError] = useState(false);
const [isMergingTags, setIsMergingTags] = useState(false);
const [openDeleteTagDialog, setOpenDeleteTagDialog] = useState(false);
const confirmDeleteTag = async () => {
const deleteTagResponse = await deleteTagAction({ tagId });
if (deleteTagResponse?.data) {
toast.success(t("environments.project.tags.tag_deleted"));
updateTagsCount();
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
}
};
return (
<div className="w-full" key={tagId}>
<div className="grid h-16 grid-cols-4 content-center rounded-lg">
<div className="col-span-2 flex items-center text-sm">
<div className="w-full text-left">
<Input
disabled={isReadOnly}
className={cn(
"w-full border font-medium text-slate-900",
updateTagError
? "border-red-500 focus:border-red-500"
: "border-slate-200 focus:border-slate-500"
)}
defaultValue={tagName}
onBlur={(e) => {
updateTagNameAction({ tagId, name: e.target.value.trim() }).then((updateTagNameResponse) => {
if (updateTagNameResponse?.data) {
setUpdateTagError(false);
toast.success(t("environments.project.tags.tag_updated"));
} else {
const errorMessage = getFormattedErrorMessage(updateTagNameResponse);
if (
errorMessage.includes(
t("environments.project.tags.unique_constraint_failed_on_the_fields")
)
) {
toast.error(t("environments.project.tags.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
} else {
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
}
setUpdateTagError(true);
}
});
}}
/>
</div>
</div>
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
</div>
{!isReadOnly && (
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
<div>
{isMergingTags ? (
<div className="w-24">
<LoadingSpinner />
</div>
) : (
<MergeTagsCombobox
tags={
environmentTags
?.filter((tag) => tag.id !== tagId)
?.map((tag) => ({ label: tag.name, value: tag.id })) ?? []
}
onSelect={(newTagId) => {
setIsMergingTags(true);
mergeTagsAction({ originalTagId: tagId, newTagId }).then((mergeTagsResponse) => {
if (mergeTagsResponse?.data) {
toast.success(t("environments.project.tags.tags_merged"));
updateTagsCount();
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(mergeTagsResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
}
setIsMergingTags(false);
});
}}
/>
)}
</div>
<div>
<Button
variant="alert"
size="sm"
// loading={isDeletingTag}
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
onClick={() => setOpenDeleteTagDialog(true)}>
{t("common.delete")}
</Button>
<DeleteDialog
open={openDeleteTagDialog}
setOpen={setOpenDeleteTagDialog}
deleteWhat={tagName}
text={t("environments.project.tags.delete_tag_confirmation")}
onDelete={confirmDeleteTag}
/>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,44 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
export const TagsLoading = () => {
const t = useTranslations();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="tags" />
</PageHeader>
<SettingsCard
title={t("environments.project.tags.manage_tags")}
description={t("environments.project.tags.manage_tags_description")}>
<div className="w-full">
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("environments.project.tags.tag")}</div>
<div className="col-span-1 text-center">{t("environments.project.tags.count")}</div>
<div className="col-span-1 flex justify-center text-center">{t("common.actions")}</div>
</div>
<div className="w-full">
{[...Array(3)].map((_, idx) => (
<div key={idx} className="grid h-16 w-full grid-cols-4 content-center">
<div className="col-span-2 h-10 animate-pulse rounded-md bg-slate-200" />
<div className="flex items-center justify-center">
<div className="h-5 w-5 animate-pulse rounded-md bg-slate-200" />
</div>
<div className="flex items-center gap-2">
<div className="h-8 w-1/2 animate-pulse rounded-md bg-slate-200" />
<div className="h-8 w-1/2 animate-pulse rounded-md bg-slate-200" />
</div>
</div>
))}
</div>
</div>
</SettingsCard>
</PageContentWrapper>
);
};
@@ -0,0 +1,87 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
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 { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTagsOnResponsesCount } from "@formbricks/lib/tagOnResponse/service";
import { EditTagsWrapper } from "./components/edit-tags-wrapper";
export const TagsPage = async (props) => {
const params = await props.params;
const t = await getTranslations();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const [tags, environmentTagsCount, organization, session, project] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getTagsOnResponsesCount(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getProjectByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!project) {
throw new Error(t("common.project_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="tags"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("environments.project.tags.manage_tags")}
description={t("environments.project.tags.manage_tags_description")}>
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={environmentTagsCount}
isReadOnly={isReadOnly}
/>
</SettingsCard>
</PageContentWrapper>
);
};
@@ -665,13 +665,13 @@ export const QuestionFormInput = ({
</div>
{usedLanguageCode !== "default" && value && typeof value["default"] !== undefined && (
<div className="mt-1 text-xs text-slate-500">
<strong>{t("environments.product.languages.translate")}:</strong>{" "}
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
{recallToHeadline(value, localSurvey, false, "default", attributeClasses)["default"]}
</div>
)}
{usedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
<div className="mt-1 text-xs text-red-400">
{t("environments.product.languages.incomplete_translations")}
{t("environments.project.languages.incomplete_translations")}
</div>
)}
</div>
@@ -2,7 +2,7 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProductIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getSurveyFollowUpsPermission } from "@/modules/ee/license-check/lib/utils";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { z } from "zod";
@@ -50,9 +50,9 @@ export const createSurveyAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "productTeam",
type: "projectTeam",
minPermission: "readWrite",
productId: await getProductIdFromEnvironmentId(parsedInput.environmentId),
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -3,7 +3,7 @@ import { PlusCircleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@formbricks/lib/cn";
import { getCustomSurveyTemplate } from "@formbricks/lib/templates";
import { TProduct } from "@formbricks/types/product";
import { TProject } from "@formbricks/types/project";
import { TTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "../lib/utils";
@@ -11,7 +11,7 @@ interface StartFromScratchTemplateProps {
activeTemplate: TTemplate | null;
setActiveTemplate: (template: TTemplate) => void;
onTemplateClick: (template: TTemplate) => void;
product: TProduct;
project: TProject;
createSurvey: (template: TTemplate) => void;
loading: boolean;
noPreview?: boolean;
@@ -22,7 +22,7 @@ export const StartFromScratchTemplate = ({
activeTemplate,
setActiveTemplate,
onTemplateClick,
product,
project,
createSurvey,
loading,
noPreview,
@@ -38,7 +38,7 @@ export const StartFromScratchTemplate = ({
createSurvey(customSurvey);
return;
}
const newTemplate = replacePresetPlaceholders(customSurvey, product);
const newTemplate = replacePresetPlaceholders(customSurvey, project);
onTemplateClick(newTemplate);
setActiveTemplate(newTemplate);
}}
@@ -1,7 +1,7 @@
import { Button } from "@/modules/ui/components/button";
import { useTranslations } from "next-intl";
import { cn } from "@formbricks/lib/cn";
import { TProduct } from "@formbricks/types/product";
import { TProject } from "@formbricks/types/project";
import { TTemplate, TTemplateFilter } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "../lib/utils";
import { TemplateTags } from "./TemplateTags";
@@ -11,7 +11,7 @@ interface TemplateProps {
activeTemplate: TTemplate | null;
setActiveTemplate: (template: TTemplate) => void;
onTemplateClick?: (template: TTemplate) => void;
product: TProduct;
project: TProject;
createSurvey: (template: TTemplate) => void;
loading: boolean;
selectedFilter: TTemplateFilter[];
@@ -23,7 +23,7 @@ export const Template = ({
activeTemplate,
setActiveTemplate,
onTemplateClick = () => {},
product,
project,
createSurvey,
loading,
selectedFilter,
@@ -33,7 +33,7 @@ export const Template = ({
return (
<div
onClick={() => {
const newTemplate = replacePresetPlaceholders(template, product);
const newTemplate = replacePresetPlaceholders(template, project);
if (noPreview) {
createSurvey(newTemplate);
return;
@@ -3,7 +3,7 @@ import { SplitIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
import { cn } from "@formbricks/lib/cn";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
import { TTemplate, TTemplateFilter, TTemplateRole } from "@formbricks/types/templates";
import { channelMapping, industryMapping, roleMapping } from "../lib/utils";
@@ -12,7 +12,7 @@ interface TemplateTagsProps {
selectedFilter: TTemplateFilter[];
}
type NonNullabeChannel = NonNullable<TProductConfigChannel>;
type NonNullabeChannel = NonNullable<TProjectConfigChannel>;
const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
switch (role) {
@@ -74,7 +74,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
);
const channelTag = useMemo(() => getChannelTag(template.channels, t), [template.channels]);
const getIndustryTag = (industries: TProductConfigIndustry[] | undefined): string | undefined => {
const getIndustryTag = (industries: TProjectConfigIndustry[] | undefined): string | undefined => {
// if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce".
if (selectedFilter[1] !== null) {
const industry = industryMapping.find((industry) => industry.value === selectedFilter[1]);
@@ -6,7 +6,7 @@ import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { templates } from "@formbricks/lib/templates";
import type { TEnvironment } from "@formbricks/types/environment";
import { type TProduct, ZProductConfigChannel, ZProductConfigIndustry } from "@formbricks/types/product";
import { type TProject, ZProjectConfigChannel, ZProjectConfigIndustry } from "@formbricks/types/project";
import { TSurveyCreateInput, TSurveyType } from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateFilter, ZTemplateRole } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
@@ -18,7 +18,7 @@ import { TemplateFilters } from "./components/TemplateFilters";
interface TemplateListProps {
user: TUser;
environment: TEnvironment;
product: TProduct;
project: TProject;
templateSearch?: string;
showFilters?: boolean;
prefilledFilters: TTemplateFilter[];
@@ -28,7 +28,7 @@ interface TemplateListProps {
export const TemplateList = ({
user,
product,
project,
environment,
showFilters = true,
templateSearch,
@@ -42,16 +42,16 @@ export const TemplateList = ({
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>(prefilledFilters);
const surveyType: TSurveyType = useMemo(() => {
if (product.config.channel) {
if (product.config.channel === "website") {
if (project.config.channel) {
if (project.config.channel === "website") {
return "app";
}
return product.config.channel;
return project.config.channel;
}
return "link";
}, [product.config.channel]);
}, [project.config.channel]);
const createSurvey = async (activeTemplate: TTemplate) => {
setLoading(true);
@@ -80,8 +80,8 @@ export const TemplateList = ({
}
// Parse and validate the filters
const channelParseResult = ZProductConfigChannel.nullable().safeParse(selectedFilter[0]);
const industryParseResult = ZProductConfigIndustry.nullable().safeParse(selectedFilter[1]);
const channelParseResult = ZProjectConfigChannel.nullable().safeParse(selectedFilter[0]);
const industryParseResult = ZProjectConfigIndustry.nullable().safeParse(selectedFilter[1]);
const roleParseResult = ZTemplateRole.nullable().safeParse(selectedFilter[2]);
// Ensure all validations are successful
@@ -119,7 +119,7 @@ export const TemplateList = ({
activeTemplate={activeTemplate}
setActiveTemplate={setActiveTemplate}
onTemplateClick={onTemplateClick}
product={product}
project={project}
createSurvey={createSurvey}
loading={loading}
noPreview={noPreview}
@@ -134,7 +134,7 @@ export const TemplateList = ({
activeTemplate={activeTemplate}
setActiveTemplate={setActiveTemplate}
onTemplateClick={onTemplateClick}
product={product}
project={project}
createSurvey={createSurvey}
loading={loading}
selectedFilter={selectedFilter}

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