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:
Anshuman Pandey
2024-06-26 21:30:50 +05:30
committed by GitHub
parent 1ff87d27ca
commit 9268407429
5 changed files with 235 additions and 63 deletions

View File

@@ -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

View File

@@ -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 = [
{

View File

@@ -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;
};

View File

@@ -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;
};

View 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;
};