mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-04 10:19:31 -06:00
fix: ee license info banner (#2790)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com> Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import type { Session } from "next-auth";
|
||||
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
@@ -13,6 +15,7 @@ import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { DevEnvironmentBanner } from "@formbricks/ui/DevEnvironmentBanner";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { LimitsReachedBanner } from "@formbricks/ui/LimitsReachedBanner";
|
||||
import { PendingDowngradeBanner } from "@formbricks/ui/PendingDowngradeBanner";
|
||||
|
||||
interface EnvironmentLayoutProps {
|
||||
environmentId: string;
|
||||
@@ -41,16 +44,42 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
const currentProductChannel =
|
||||
products.find((product) => product.id === environment.productId)?.config.channel ?? null;
|
||||
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
|
||||
{IS_FORMBRICKS_CLOUD && <LimitsReachedBanner organization={organization} />}
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<LimitsReachedBanner
|
||||
organization={organization}
|
||||
environmentId={environment.id}
|
||||
peopleCount={peopleCount}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PendingDowngradeBanner
|
||||
lastChecked={lastChecked}
|
||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
<MainNavigation
|
||||
|
||||
@@ -2,7 +2,7 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsEnterpriseEdition } from "@formbricks/ee/lib/service";
|
||||
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
@@ -37,7 +37,7 @@ const Page = async ({ params }) => {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isEnterpriseEdition = await getIsEnterpriseEdition();
|
||||
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
|
||||
const paidFeatures = [
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const
|
||||
|
||||
// This function is used to get the previous result of the license check from the cache
|
||||
// This might seem confusing at first since we only return the default value from this function,
|
||||
// but since we are using a cache and the cache key is the same, the cache will return the previous result - so this functions as a cache getter
|
||||
// but since we are using a cache and the cache key is the same, the cache will return the previous result - so this function acts as a cache getter
|
||||
const getPreviousResult = (): Promise<{
|
||||
active: boolean | null;
|
||||
lastChecked: Date;
|
||||
@@ -89,47 +89,87 @@ const fetchLicenseForE2ETesting = async (): Promise<{
|
||||
}
|
||||
};
|
||||
|
||||
export const getIsEnterpriseEdition = async (): Promise<boolean> => {
|
||||
export const getEnterpriseLicense = async (): Promise<{
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade?: boolean;
|
||||
}> => {
|
||||
if (!ENTERPRISE_LICENSE_KEY || ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
return false;
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.active !== null ? previousResult.active : false;
|
||||
|
||||
return {
|
||||
active: previousResult?.active ?? false,
|
||||
features: previousResult ? previousResult.features : null,
|
||||
lastChecked: previousResult ? previousResult.lastChecked : new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// if the server responds with a boolean, we return it
|
||||
// if the server errors, we return null
|
||||
// null signifies an error
|
||||
const license = await fetchLicense();
|
||||
|
||||
const isValid = license ? license.status === "active" : null;
|
||||
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
|
||||
const currentTime = new Date();
|
||||
|
||||
const previousResult = await getPreviousResult();
|
||||
|
||||
// Case: First time checking license and the server errors out
|
||||
if (previousResult.active === null) {
|
||||
if (isValid === null) {
|
||||
await setPreviousResult({
|
||||
const newResult = {
|
||||
active: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
await setPreviousResult(newResult);
|
||||
return newResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid !== null && license) {
|
||||
await setPreviousResult({ active: isValid, features: license.features, lastChecked: new Date() });
|
||||
return isValid;
|
||||
const newResult = {
|
||||
active: isValid,
|
||||
features: license.features,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
|
||||
await setPreviousResult(newResult);
|
||||
return newResult;
|
||||
} else {
|
||||
// if result is undefined -> error
|
||||
// if the last check was less than 72 hours, return the previous value:
|
||||
if (new Date().getTime() - previousResult.lastChecked.getTime() <= 3 * 24 * 60 * 60 * 1000) {
|
||||
return previousResult.active !== null ? previousResult.active : false;
|
||||
|
||||
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
||||
if (elapsedTime < threeDaysInMillis) {
|
||||
return {
|
||||
active: previousResult.active !== null ? previousResult.active : false,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
};
|
||||
}
|
||||
|
||||
// if the last check was more than 72 hours, return false and log the error
|
||||
// Log error only after 72 hours
|
||||
console.error("Error while checking license: The license check failed");
|
||||
return false;
|
||||
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,14 +179,13 @@ export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures |
|
||||
return previousResult.features;
|
||||
} else {
|
||||
const license = await fetchLicense();
|
||||
if (!license) return null;
|
||||
const features = await license.features;
|
||||
return features;
|
||||
if (!license || !license.features) return null;
|
||||
return license.features;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLicense = async () => {
|
||||
const licenseResult: TEnterpriseLicenseDetails | null = await cache(
|
||||
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> =>
|
||||
await cache(
|
||||
async () => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
try {
|
||||
@@ -192,8 +231,6 @@ export const fetchLicense = async () => {
|
||||
[`fetchLicense-${hashedKey}`],
|
||||
{ revalidate: 60 * 60 * 24 }
|
||||
)();
|
||||
return licenseResult;
|
||||
};
|
||||
|
||||
export const getRemoveInAppBrandingPermission = (organization: TOrganization): boolean => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
|
||||
@@ -213,7 +250,7 @@ export const getRoleManagementPermission = async (organization: TOrganization):
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -223,13 +260,13 @@ export const getAdvancedTargetingPermission = async (organization: TOrganization
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
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;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -243,7 +280,7 @@ export const getMultiLanguagePermission = async (organization: TOrganization): P
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,53 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { TriangleAlertIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { useState } from "react";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
interface LimitsReachedBannerProps {
|
||||
organization: TOrganization;
|
||||
environmentId: string;
|
||||
peopleCount: number;
|
||||
responseCount: number;
|
||||
}
|
||||
|
||||
export const LimitsReachedBanner = async ({ organization }: LimitsReachedBannerProps) => {
|
||||
const [peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
|
||||
export const LimitsReachedBanner = ({
|
||||
organization,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
environmentId,
|
||||
}: LimitsReachedBannerProps) => {
|
||||
const orgBillingPeopleLimit = organization.billing?.limits?.monthly?.miu;
|
||||
const orgBillingResponseLimit = organization.billing?.limits?.monthly?.responses;
|
||||
|
||||
const isPeopleLimitReached = orgBillingPeopleLimit !== null && peopleCount >= orgBillingPeopleLimit;
|
||||
const isResponseLimitReached = orgBillingResponseLimit !== null && responseCount >= orgBillingResponseLimit;
|
||||
|
||||
if (isPeopleLimitReached && isResponseLimitReached) {
|
||||
const [show, setShow] = useState(true);
|
||||
|
||||
if (show && (isPeopleLimitReached || isResponseLimitReached)) {
|
||||
return (
|
||||
<>
|
||||
<div className="z-40 flex h-5 items-center justify-center bg-orange-800 text-center text-xs text-white">
|
||||
You have reached your monthly MIU limit of {orgBillingPeopleLimit} and response limit of{" "}
|
||||
{orgBillingResponseLimit}. <Link href="https://formbricks.com/pricing#faq">Learn more</Link>
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-[100] flex min-w-80 items-end px-4 py-6 sm:items-start sm:p-6">
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition">
|
||||
<div className="p-4">
|
||||
<div className="relative flex flex-col">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<TriangleAlertIcon className="text-error h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-base font-medium text-gray-900">Limits Reached</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{isPeopleLimitReached && isResponseLimitReached ? (
|
||||
<>
|
||||
You have reached your monthly MIU limit of <span>{orgBillingPeopleLimit}</span> and
|
||||
response limit of {orgBillingResponseLimit}.{" "}
|
||||
</>
|
||||
) : null}
|
||||
{isPeopleLimitReached && !isResponseLimitReached ? (
|
||||
<>You have reached your monthly MIU limit of {orgBillingPeopleLimit}. </>
|
||||
) : null}
|
||||
{!isPeopleLimitReached && isResponseLimitReached ? (
|
||||
<>You have reached your monthly response limit of {orgBillingResponseLimit}. </>
|
||||
) : null}
|
||||
</p>
|
||||
<Link href={`/environments/${environmentId}/settings/billing`}>
|
||||
<span className="text-sm text-slate-900">Learn more</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
onClick={() => setShow(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="z-40 flex h-5 items-center justify-center bg-orange-800 text-center text-xs text-white">
|
||||
{isPeopleLimitReached && (
|
||||
<div>
|
||||
You have reached your monthly MIU limit of {orgBillingPeopleLimit}.{" "}
|
||||
<Link href="https://formbricks.com/pricing#faq">Learn more</Link>
|
||||
</div>
|
||||
)}
|
||||
{isResponseLimitReached && (
|
||||
<div>
|
||||
You have reached your monthly response limit of {orgBillingResponseLimit}.{" "}
|
||||
<Link href="https://formbricks.com/pricing#faq">Learn more</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
77
packages/ui/PendingDowngradeBanner/index.tsx
Normal file
77
packages/ui/PendingDowngradeBanner/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { TriangleAlertIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PendingDowngradeBannerProps {
|
||||
lastChecked: Date;
|
||||
active: boolean;
|
||||
isPendingDowngrade: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const PendingDowngradeBanner = ({
|
||||
lastChecked,
|
||||
active,
|
||||
isPendingDowngrade,
|
||||
environmentId,
|
||||
}: PendingDowngradeBannerProps) => {
|
||||
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const isLastCheckedWithin72Hours = lastChecked
|
||||
? new Date().getTime() - lastChecked.getTime() < threeDaysInMillis
|
||||
: false;
|
||||
|
||||
const scheduledDowngradeDate = new Date(lastChecked.getTime() + threeDaysInMillis);
|
||||
const formattedDate = `${scheduledDowngradeDate.getMonth() + 1}/${scheduledDowngradeDate.getDate()}/${scheduledDowngradeDate.getFullYear()}`;
|
||||
|
||||
const [show, setShow] = useState(true);
|
||||
|
||||
if (show && active && isPendingDowngrade) {
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-[100] flex min-w-80 items-end px-4 py-6 sm:items-start sm:p-6">
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition">
|
||||
<div className="p-4">
|
||||
<div className="relative flex flex-col">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<TriangleAlertIcon className="text-error h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-base font-medium text-gray-900">Pending Downgrade</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
We were unable to verify your license because the license server is unreachable.{" "}
|
||||
{isLastCheckedWithin72Hours
|
||||
? `You will be downgraded to the Community Edition on ${formattedDate}.`
|
||||
: "You are downgraded to the Community Edition."}
|
||||
</p>
|
||||
|
||||
<Link href={`/environments/${environmentId}/settings/enterprise`}>
|
||||
<span className="text-sm text-slate-900">Learn more</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
onClick={() => setShow(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user