mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 05:17:49 -05:00
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:
@@ -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),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+1
-1
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 hasn’t 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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
+19
-19
@@ -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
|
||||
);
|
||||
+15
-15
@@ -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);
|
||||
+12
-12
@@ -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}
|
||||
/>
|
||||
+6
-6
@@ -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}
|
||||
+11
-11
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
+61
-61
@@ -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;
|
||||
+12
-12
@@ -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>
|
||||
+2
-2
@@ -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);
|
||||
});
|
||||
|
||||
+16
-16
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
+59
-59
@@ -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>;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
+4
-4
@@ -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
Reference in New Issue
Block a user