Billing for self-hosts (#18075)
## Summary Implements enterprise licensing and per-seat billing for self-hosted environments, with Stripe as the single source of truth for subscription data. ### Components - **twenty-website** hosts the private key to sign `ENTERPRISE_KEY` and `ENTERPRISE_VALIDITY_TOKEN`. It communicates with Stripe to emit the daily `ENTERPRISE_VALIDITY_TOKEN` if the subscription is active, based on the user's Stripe subscription ID stored in `ENTERPRISE_KEY`. - **Stripe** is the single source of truth for subscription data (status, seats, billing). - **The client** (twenty-server + DB + workers) saves `ENTERPRISE_KEY` in the `keyValuePair` table (or `.env` if `IS_CONFIG_VARIABLES_IN_DB_ENABLED` is false) and the daily-renewed `ENTERPRISE_VALIDITY_TOKEN` in the `appToken` table. `ENTERPRISE_VALIDITY_TOKEN` is verified client-side using a public key to grant access to enterprise features (RLS, SSO, audit logs, etc.). ### Flow 1. When requesting an upgrade to an enterprise plan (from **Enterprise** in settings), the user is shown a modal to choose monthly/yearly billing, then redirected to Stripe to enter payment details. After checkout, they land on twenty-website where they are exposed to their `ENTERPRISE_KEY`, which they paste in the UI. It is saved in the `keyValuePair` table. On activation, a first `ENTERPRISE_VALIDITY_TOKEN` with 30-day validity is stored in the `appToken` table. 2. **Every day**, a cron job runs and does two things: - **Refreshes the validity token**: communicates with twenty-website to get a new `ENTERPRISE_VALIDITY_TOKEN` with 30-day validity if the Stripe subscription is still active. If the subscription is in cancellation, the emitted token has a validity equal to the cancellation date. If it's no longer valid, the token is not replaced. The cron only needs to run every 30 days in practice, but runs daily so it's resilient to occasional failures. - **Reports seat count**: counts active (non-soft-deleted) `UserWorkspace` entries and sends the count to twenty-website, which updates the Stripe subscription quantity with proration. Seats are also reported on first activation. If the subscription is canceled or scheduled for cancellation, the seat update is skipped. 3. `ENTERPRISE_VALIDITY_TOKEN` is verified server-side via a public key to grant access to enterprise features. ### Key concepts Three distinct checks are exposed as GraphQL fields on `Workspace`: | Field | Meaning | |---|---| | `hasValidEnterpriseKey` | Has any valid enterprise key (signed JWT **or** legacy plain string) | | `hasValidSignedEnterpriseKey` | `ENTERPRISE_KEY` is a properly signed JWT (billing portal makes sense) | | `hasValidEnterpriseValidityToken` | `ENTERPRISE_VALIDITY_TOKEN` is present and not expired (expiration depends on signed token payload, not on "expiresAt" on appToken table which is only indicative) | Feature access is gated by `isValid()` = `hasValidEnterpriseValidityToken || hasValidEnterpriseKey` (to support both new and legacy keys during transition). After transition isValid() = hasValidEnterpriseValidityToken ### Frontend states The Enterprise settings page handles multiple states: - **No key**: show "Get Enterprise" with checkout modal - **Orphaned validity token** (token valid but no signed key): prompt user to set a valid enterprise key - **Active/trialing but no validity token**: show subscription status with a "Reload validity token" action - **Active/trialing**: show full subscription info, billing portal access, cancel option - **Cancellation scheduled**: show cancellation date, billing portal - **Canceled**: show billing history link and option to start a new subscription - **Past due / Incomplete**: prompt to update payment or restart ### Temporary retro-compatibility: legacy plain-text keys Previously, enterprise features were gated by a simple check: any non-empty string in `ENTERPRISE_KEY` granted access. With this PR, we transition to a controlled system relying on signed JWTs. To avoid breaking existing self-hosted users: - **Legacy plain-text keys still grant access** to enterprise features. `hasValidEnterpriseKey` returns `true` for both signed JWTs and plain strings, and `isValid()` checks `hasValidEnterpriseKey` as a fallback when no validity token is present. - **A deprecation banner** is shown at the top of the app when `hasValidEnterpriseKey` is `true` but `hasValidSignedEnterpriseKey` is `false`, informing the user that their key format is deprecated and they should activate a new signed key. - **No billing portal or subscription management** is available for legacy keys since there is no Stripe subscription to manage. This retro-compatibility will be removed in a future version. At that point, `isValid()` will only check `hasValidEnterpriseValidityToken`. ### Edge cases - **Air-gapped / production environments**: for self-hosted clients that block external traffic (or for our own production), provide a long-lived `ENTERPRISE_VALIDITY_TOKEN` (e.g. 99 years) directly in the `appToken` table, with no `ENTERPRISE_KEY`. The daily cron will skip the refresh (no enterprise key to authenticate with), but the pre-seeded validity token will be used to grant feature access. No billing or seat reporting occurs in this mode. - **`IS_CONFIG_VARIABLES_IN_DB_ENABLED` is false**: if the user tries to activate an enterprise key but DB config writes are disabled, the backend returns a clear error asking them to add `ENTERPRISE_KEY` to their `.env` file manually. - **Canceled subscriptions**: the `/seats` endpoint skips Stripe updates for canceled or cancellation-scheduled subscriptions to avoid Stripe API errors. ### How to test - launch twenty-website on a different url (eg localhost:1002) - add ENTERPRISE_API_URL=http://localhost:3002/api/enterprise (or else) in your server .env - ask me for twenty-website's .env file content (STRIPE_SECRET_KEY; STRIPE_ENTERPRISE_MONTHLY_PRICE_ID;STRIPE_ENTERPRISE_YEARLY_PRICE_ID; ENTERPRISE_JWT_PRIVATE_KEY; ENTERPRISE_JWT_PUBLIC_KEY; NEXT_PUBLIC_WEBSITE_URL) - visit Admin panel / enterprise
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -58,6 +58,9 @@ const mockWorkspace = {
|
||||
isPasswordAuthBypassEnabled: false,
|
||||
isMicrosoftAuthBypassEnabled: false,
|
||||
hasValidEnterpriseKey: false,
|
||||
hasActivatedAndValidEnterpriseKey: false,
|
||||
hasValidSignedEnterpriseKey: false,
|
||||
hasValidEnterpriseValidityToken: false,
|
||||
subdomain: 'test',
|
||||
customDomain: 'test.com',
|
||||
workspaceUrls: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { SettingsProtectedRouteWrapper } from '@/settings/components/SettingsProtectedRouteWrapper';
|
||||
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
|
||||
import { SettingPublicDomain } from '@/settings/domains/components/SettingPublicDomain';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
PermissionFlagType,
|
||||
@@ -662,6 +663,15 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
{isAdminPageEnabled && (
|
||||
<>
|
||||
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path={SettingsPath.Enterprise}
|
||||
element={
|
||||
<Navigate
|
||||
to={getSettingsPath(SettingsPath.AdminPanelEnterprise)}
|
||||
replace
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.AdminPanelIndicatorHealthStatus}
|
||||
element={<SettingsAdminIndicatorHealthStatus />}
|
||||
|
||||
@@ -27,6 +27,8 @@ export type CurrentWorkspace = Pick<
|
||||
| 'isPasswordAuthBypassEnabled'
|
||||
| 'isCustomDomainEnabled'
|
||||
| 'hasValidEnterpriseKey'
|
||||
| 'hasValidSignedEnterpriseKey'
|
||||
| 'hasValidEnterpriseValidityToken'
|
||||
| 'subdomain'
|
||||
| 'customDomain'
|
||||
| 'workspaceUrls'
|
||||
|
||||
@@ -15,7 +15,6 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
const StyledText = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledCloseButtonContainer = styled.div`
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import { InformationBannerBillingSubscriptionPaused } from '@/information-banner
|
||||
import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod';
|
||||
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
|
||||
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
|
||||
import { InformationBannerLegacyEnterpriseKey } from '@/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey';
|
||||
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
|
||||
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
@@ -52,6 +53,7 @@ export const InformationBannerWrapper = () => {
|
||||
|
||||
return (
|
||||
<StyledInformationBannerWrapper>
|
||||
<InformationBannerLegacyEnterpriseKey />
|
||||
{isAccountSyncEnabled && (
|
||||
<InformationBannerReconnectAccountInsufficientPermissions />
|
||||
)}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||
import { informationBannerIsOpenComponentState } from '@/information-banner/states/informationBannerIsOpenComponentState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconKey } from 'twenty-ui/display';
|
||||
|
||||
const COMPONENT_INSTANCE_ID = 'information-banner-legacy-enterprise-key';
|
||||
|
||||
export const InformationBannerLegacyEnterpriseKey = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
|
||||
const setInformationBannerIsOpen = useSetAtomComponentState(
|
||||
informationBannerIsOpenComponentState,
|
||||
COMPONENT_INSTANCE_ID,
|
||||
);
|
||||
|
||||
const hasLegacyKey =
|
||||
currentWorkspace?.hasValidEnterpriseKey === true &&
|
||||
currentWorkspace?.hasValidSignedEnterpriseKey !== true;
|
||||
|
||||
if (!hasLegacyKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId={COMPONENT_INSTANCE_ID}
|
||||
variant="default"
|
||||
message={t`Your enterprise key format is deprecated. Please activate a new key to keep enterprise features.`}
|
||||
buttonTitle={t`Activate`}
|
||||
buttonIcon={IconKey}
|
||||
buttonOnClick={() =>
|
||||
navigate(getSettingsPath(SettingsPath.AdminPanelEnterprise))
|
||||
}
|
||||
onClose={() => setInformationBannerIsOpen(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+2
@@ -181,6 +181,8 @@ export const responseData = {
|
||||
activationStatus: 'active',
|
||||
isPublicInviteLinkEnabled: false,
|
||||
hasValidEnterpriseKey: false,
|
||||
hasValidSignedEnterpriseKey: false,
|
||||
hasValidEnterpriseValidityToken: false,
|
||||
isGoogleAuthEnabled: true,
|
||||
isMicrosoftAuthEnabled: false,
|
||||
isPasswordAuthEnabled: true,
|
||||
|
||||
+2
@@ -33,6 +33,8 @@ describe('useColumnDefinitionsFromObjectMetadata', () => {
|
||||
subdomain: 'test',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
hasValidEnterpriseKey: false,
|
||||
hasValidSignedEnterpriseKey: false,
|
||||
hasValidEnterpriseValidityToken: false,
|
||||
metadataVersion: 1,
|
||||
isPublicInviteLinkEnabled: false,
|
||||
isGoogleAuthEnabled: true,
|
||||
|
||||
+15
-1
@@ -1,23 +1,27 @@
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { SettingsAdminTabContent } from '@/settings/admin-panel/components/SettingsAdminTabContent';
|
||||
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
|
||||
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
IconApps,
|
||||
IconHeart,
|
||||
IconKey,
|
||||
IconSettings2,
|
||||
IconSparkles,
|
||||
IconVariable,
|
||||
} from 'twenty-ui/display';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const SettingsAdminContent = () => {
|
||||
const currentUser = useAtomStateValue(currentUserState);
|
||||
const billing = useAtomStateValue(billingState);
|
||||
|
||||
const canAccessFullAdminPanel = currentUser?.canAccessFullAdminPanel;
|
||||
const canImpersonate = currentUser?.canImpersonate;
|
||||
const isBillingEnabled = billing?.isBillingEnabled;
|
||||
const tabs = [
|
||||
{
|
||||
id: SETTINGS_ADMIN_TABS.GENERAL,
|
||||
@@ -49,6 +53,16 @@ export const SettingsAdminContent = () => {
|
||||
Icon: IconHeart,
|
||||
disabled: !canAccessFullAdminPanel,
|
||||
},
|
||||
...(!isBillingEnabled
|
||||
? [
|
||||
{
|
||||
id: SETTINGS_ADMIN_TABS.ENTERPRISE,
|
||||
title: t`Enterprise`,
|
||||
Icon: IconKey,
|
||||
disabled: !canAccessFullAdminPanel && !canImpersonate,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
+13
@@ -5,9 +5,16 @@ import { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-vari
|
||||
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
|
||||
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
|
||||
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
|
||||
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const SettingsEnterprise = lazy(() =>
|
||||
import('~/pages/settings/enterprise/SettingsEnterprise').then((module) => ({
|
||||
default: module.SettingsEnterprise,
|
||||
})),
|
||||
);
|
||||
export const SettingsAdminTabContent = () => {
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
@@ -25,6 +32,12 @@ export const SettingsAdminTabContent = () => {
|
||||
return <SettingsAdminConfigVariables />;
|
||||
case SETTINGS_ADMIN_TABS.HEALTH_STATUS:
|
||||
return <SettingsAdminHealthStatus />;
|
||||
case SETTINGS_ADMIN_TABS.ENTERPRISE:
|
||||
return (
|
||||
<Suspense fallback={<SettingsSkeletonLoader />}>
|
||||
<SettingsEnterprise isAdminPanelTab />
|
||||
</Suspense>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ export const SETTINGS_ADMIN_TABS = {
|
||||
AI: 'ai',
|
||||
CONFIG_VARIABLES: 'config-variables',
|
||||
HEALTH_STATUS: 'health-status',
|
||||
ENTERPRISE: 'enterprise',
|
||||
};
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { SubTitle } from '@/auth/components/SubTitle';
|
||||
import { Title } from '@/auth/components/Title';
|
||||
import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit';
|
||||
import { ENTERPRISE_CHECKOUT_SESSION } from '@/settings/enterprise/graphql/queries/enterpriseCheckoutSession';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useLazyQuery } from '@apollo/client';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { Loader } from 'twenty-ui/feedback';
|
||||
import { CardPicker, MainButton } from 'twenty-ui/input';
|
||||
import { ModalContent } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export const ENTERPRISE_PLAN_MODAL_ID = 'enterprise-plan-modal';
|
||||
|
||||
type BillingInterval = 'monthly' | 'yearly';
|
||||
|
||||
const MONTHLY_PRICE = 25;
|
||||
const YEARLY_PRICE = 19;
|
||||
|
||||
const StyledSubscriptionContainer = styled.div`
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: ${themeCssVariables.spacing[8]} 0 ${themeCssVariables.spacing[2]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledPriceContainer = styled.div`
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]} 0
|
||||
${themeCssVariables.spacing[4]};
|
||||
padding-bottom: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledPrice = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.xxl};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin-bottom: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledPriceUnit = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledBenefitsContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledIntervalContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
margin-bottom: ${themeCssVariables.spacing[8]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledIntervalTitle = styled.div`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
`;
|
||||
|
||||
export const EnterprisePlanModal = () => {
|
||||
const { t } = useLingui();
|
||||
const { closeModal } = useModal();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [selectedInterval, setSelectedInterval] =
|
||||
useState<BillingInterval>('monthly');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [fetchCheckoutSession] = useLazyQuery(ENTERPRISE_CHECKOUT_SESSION);
|
||||
|
||||
const benefits = [
|
||||
t`SSO (SAML / OIDC)`,
|
||||
t`Row-level security`,
|
||||
t`Audit logs`,
|
||||
t`Custom objects`,
|
||||
t`API & Webhooks`,
|
||||
];
|
||||
|
||||
const price = selectedInterval === 'monthly' ? MONTHLY_PRICE : YEARLY_PRICE;
|
||||
const priceUnit =
|
||||
selectedInterval === 'monthly'
|
||||
? t`seat / month`
|
||||
: t`seat / month - billed yearly`;
|
||||
|
||||
const handleContinue = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { data } = await fetchCheckoutSession({
|
||||
variables: { billingInterval: selectedInterval },
|
||||
});
|
||||
|
||||
const checkoutUrl = data?.enterpriseCheckoutSession;
|
||||
|
||||
if (checkoutUrl !== null && checkoutUrl !== undefined) {
|
||||
window.open(checkoutUrl, '_blank', 'noopener');
|
||||
closeModal(ENTERPRISE_PLAN_MODAL_ID);
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not open Stripe. Please contact support.`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error opening Stripe`,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalStatefulWrapper
|
||||
modalInstanceId={ENTERPRISE_PLAN_MODAL_ID}
|
||||
size="small"
|
||||
padding="none"
|
||||
isClosable
|
||||
>
|
||||
<ModalContent isVerticallyCentered>
|
||||
<Title noMarginTop>{t`Get Enterprise`}</Title>
|
||||
<SubTitle>{t`Enjoy a 30-day free trial`}</SubTitle>
|
||||
|
||||
<StyledSubscriptionContainer>
|
||||
<StyledPriceContainer>
|
||||
<StyledPrice>{`$${price}`}</StyledPrice>
|
||||
<StyledPriceUnit>{priceUnit}</StyledPriceUnit>
|
||||
</StyledPriceContainer>
|
||||
<StyledBenefitsContainer>
|
||||
{benefits.map((benefit) => (
|
||||
<SubscriptionBenefit key={benefit}>{benefit}</SubscriptionBenefit>
|
||||
))}
|
||||
</StyledBenefitsContainer>
|
||||
</StyledSubscriptionContainer>
|
||||
|
||||
<StyledIntervalContainer>
|
||||
<CardPicker
|
||||
checked={selectedInterval === 'monthly'}
|
||||
handleChange={() => setSelectedInterval('monthly')}
|
||||
>
|
||||
<StyledIntervalTitle>{t`Monthly subscription`}</StyledIntervalTitle>
|
||||
</CardPicker>
|
||||
<CardPicker
|
||||
checked={selectedInterval === 'yearly'}
|
||||
handleChange={() => setSelectedInterval('yearly')}
|
||||
>
|
||||
<StyledIntervalTitle>{t`Yearly subscription`}</StyledIntervalTitle>
|
||||
</CardPicker>
|
||||
</StyledIntervalContainer>
|
||||
|
||||
<MainButton
|
||||
title={t`Continue`}
|
||||
onClick={handleContinue}
|
||||
width={200}
|
||||
Icon={() => isLoading && <Loader />}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</ModalContent>
|
||||
</ModalStatefulWrapper>
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REFRESH_ENTERPRISE_VALIDITY_TOKEN = gql`
|
||||
mutation RefreshEnterpriseValidityToken {
|
||||
refreshEnterpriseValidityToken
|
||||
}
|
||||
`;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SET_ENTERPRISE_KEY = gql`
|
||||
mutation SetEnterpriseKey($enterpriseKey: String!) {
|
||||
setEnterpriseKey(enterpriseKey: $enterpriseKey) {
|
||||
isValid
|
||||
licensee
|
||||
expiresAt
|
||||
subscriptionId
|
||||
}
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ENTERPRISE_CHECKOUT_SESSION = gql`
|
||||
query EnterpriseCheckoutSession($billingInterval: String) {
|
||||
enterpriseCheckoutSession(billingInterval: $billingInterval)
|
||||
}
|
||||
`;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ENTERPRISE_PORTAL_SESSION = gql`
|
||||
query EnterprisePortalSession($returnUrlPath: String) {
|
||||
enterprisePortalSession(returnUrlPath: $returnUrlPath)
|
||||
}
|
||||
`;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const ENTERPRISE_SUBSCRIPTION_STATUS = gql`
|
||||
query EnterpriseSubscriptionStatus {
|
||||
enterpriseSubscriptionStatus {
|
||||
status
|
||||
licensee
|
||||
expiresAt
|
||||
cancelAt
|
||||
currentPeriodEnd
|
||||
isCancellationScheduled
|
||||
}
|
||||
}
|
||||
`;
|
||||
+15
-12
@@ -17,7 +17,6 @@ import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
const StyledContent = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
padding-top: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledCardContainer = styled.div`
|
||||
@@ -70,18 +69,22 @@ export const SettingsRolePermissionsObjectLevelRecordLevelSection = ({
|
||||
<SettingsOptionCardContentButton
|
||||
Icon={IconLock}
|
||||
title={t`Upgrade to access`}
|
||||
description={t`This feature is part of the Organization Plan`}
|
||||
description={t`This feature is part of the Enterprise Plan`}
|
||||
Button={
|
||||
isBillingEnabled && (
|
||||
<Button
|
||||
title={t`Upgrade`}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
size="small"
|
||||
Icon={IconArrowUp}
|
||||
onClick={() => navigateSettings(SettingsPath.Billing)}
|
||||
/>
|
||||
)
|
||||
<Button
|
||||
title={t`Upgrade`}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
size="small"
|
||||
Icon={IconArrowUp}
|
||||
onClick={() =>
|
||||
navigateSettings(
|
||||
isBillingEnabled
|
||||
? SettingsPath.Billing
|
||||
: SettingsPath.AdminPanelEnterprise,
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { TabListHiddenMeasurements } from '@/ui/layout/tab-list/components/TabListHiddenMeasurements';
|
||||
import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
|
||||
import { TAB_LIST_HEIGHT } from '@/ui/layout/tab-list/constants/TabListHeight';
|
||||
import { useTabListMeasurements } from '@/ui/layout/tab-list/hooks/useTabListMeasurements';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
|
||||
import { type TabListProps } from '@/ui/layout/tab-list/types/TabListProps';
|
||||
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
|
||||
import { TabListHiddenMeasurements } from '@/ui/layout/tab-list/components/TabListHiddenMeasurements';
|
||||
import { useTabListMeasurements } from '@/ui/layout/tab-list/hooks/useTabListMeasurements';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TabButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { TabListDropdown } from './TabListDropdown';
|
||||
import { TabListFromUrlOptionalEffect } from './TabListFromUrlOptionalEffect';
|
||||
|
||||
@@ -23,16 +22,6 @@ const StyledContainer = styled.div`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
|
||||
&::after {
|
||||
background-color: ${themeCssVariables.border.color.light};
|
||||
bottom: 0;
|
||||
content: '';
|
||||
height: 1px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDropdownContainer = styled.div`
|
||||
|
||||
@@ -61,6 +61,8 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
subdomain
|
||||
customDomain
|
||||
hasValidEnterpriseKey
|
||||
hasValidSignedEnterpriseKey
|
||||
hasValidEnterpriseValidityToken
|
||||
workspaceCustomApplication {
|
||||
id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import { SubscriptionInfoRowContainer } from '@/billing/components/internal/SubscriptionInfoRowContainer';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import {
|
||||
ENTERPRISE_PLAN_MODAL_ID,
|
||||
EnterprisePlanModal,
|
||||
} from '@/settings/enterprise/components/EnterprisePlanModal';
|
||||
import { REFRESH_ENTERPRISE_VALIDITY_TOKEN } from '@/settings/enterprise/graphql/mutations/refreshEnterpriseValidityToken';
|
||||
import { SET_ENTERPRISE_KEY } from '@/settings/enterprise/graphql/mutations/setEnterpriseKey';
|
||||
import { ENTERPRISE_PORTAL_SESSION } from '@/settings/enterprise/graphql/queries/enterprisePortalSession';
|
||||
import { ENTERPRISE_SUBSCRIPTION_STATUS } from '@/settings/enterprise/graphql/queries/enterpriseSubscriptionStatus';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
|
||||
import { ApolloError, useLazyQuery, useMutation } from '@apollo/client';
|
||||
import { styled } from '@linaria/react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
H2Title,
|
||||
IconCalendarRepeat,
|
||||
IconCheck,
|
||||
IconCircleX,
|
||||
IconCreditCard,
|
||||
IconKey,
|
||||
IconUser,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type SettingsEnterpriseProps = {
|
||||
isAdminPanelTab?: boolean;
|
||||
};
|
||||
|
||||
type SubscriptionStatus = {
|
||||
status: string | null;
|
||||
licensee: string | null;
|
||||
expiresAt: string | null;
|
||||
cancelAt: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
isCancellationScheduled: boolean;
|
||||
};
|
||||
|
||||
const StyledStatusDot = styled.div<{ isActive: boolean }>`
|
||||
background-color: ${({ isActive }) =>
|
||||
isActive ? themeCssVariables.color.green : themeCssVariables.color.red};
|
||||
border-radius: 50%;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
`;
|
||||
|
||||
const StyledStatusContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledCancellationNotice = styled.div`
|
||||
color: ${themeCssVariables.font.color.danger};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
margin-top: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInputWrapper = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledActivateButtonWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledSpacer = styled.div`
|
||||
height: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const SettingsEnterprise = ({
|
||||
isAdminPanelTab = false,
|
||||
}: SettingsEnterpriseProps = {}) => {
|
||||
const { t } = useLingui();
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const [enterpriseKey, setEnterpriseKey] = useState('');
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [setEnterpriseKeyMutation] = useMutation(SET_ENTERPRISE_KEY);
|
||||
const [refreshValidityTokenMutation] = useMutation(
|
||||
REFRESH_ENTERPRISE_VALIDITY_TOKEN,
|
||||
);
|
||||
const [fetchPortalSession] = useLazyQuery(ENTERPRISE_PORTAL_SESSION);
|
||||
const [isRefreshingToken, setIsRefreshingToken] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
||||
const { loadCurrentUser } = useLoadCurrentUser();
|
||||
|
||||
const hasSignedEnterpriseKey =
|
||||
currentWorkspace?.hasValidSignedEnterpriseKey === true;
|
||||
const hasValidityToken =
|
||||
currentWorkspace?.hasValidEnterpriseValidityToken === true;
|
||||
|
||||
const hasOrphanedValidityToken = hasValidityToken && !hasSignedEnterpriseKey;
|
||||
|
||||
const [fetchSubscriptionStatus] = useLazyQuery(
|
||||
ENTERPRISE_SUBSCRIPTION_STATUS,
|
||||
{ fetchPolicy: 'network-only' },
|
||||
);
|
||||
|
||||
const [subscriptionStatus, setSubscriptionStatus] =
|
||||
useState<SubscriptionStatus | null>(null);
|
||||
const [isStatusLoaded, setIsStatusLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSignedEnterpriseKey) {
|
||||
setIsStatusLoaded(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const loadStatus = async () => {
|
||||
const { data } = await fetchSubscriptionStatus();
|
||||
|
||||
setSubscriptionStatus(data?.enterpriseSubscriptionStatus ?? null);
|
||||
setIsStatusLoaded(true);
|
||||
};
|
||||
|
||||
loadStatus();
|
||||
}, [hasSignedEnterpriseKey, fetchSubscriptionStatus]);
|
||||
|
||||
const stripeStatus = subscriptionStatus?.status ?? null;
|
||||
|
||||
const isSubscriptionActiveOrTrialing =
|
||||
stripeStatus === 'active' || stripeStatus === 'trialing';
|
||||
const isCancelScheduled =
|
||||
subscriptionStatus?.isCancellationScheduled === true;
|
||||
const isCanceled = stripeStatus === 'canceled';
|
||||
const isPastDue = stripeStatus === 'past_due' || stripeStatus === 'unpaid';
|
||||
const isIncomplete =
|
||||
stripeStatus === 'incomplete' || stripeStatus === 'incomplete_expired';
|
||||
|
||||
const licensee = subscriptionStatus?.licensee ?? null;
|
||||
const expiresAt = subscriptionStatus?.expiresAt
|
||||
? new Date(subscriptionStatus.expiresAt)
|
||||
: null;
|
||||
|
||||
const cancelAt = isDefined(subscriptionStatus?.cancelAt)
|
||||
? new Date(subscriptionStatus.cancelAt)
|
||||
: null;
|
||||
|
||||
const cancelAtDate =
|
||||
isCancelScheduled && isDefined(cancelAt)
|
||||
? cancelAt.toLocaleDateString()
|
||||
: '';
|
||||
|
||||
const cancellationMessage =
|
||||
isCancelScheduled && isDefined(cancelAt)
|
||||
? t`Your enterprise features will remain active until ${cancelAtDate}.`
|
||||
: null;
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
if (!enterpriseKey.trim()) return;
|
||||
|
||||
setIsActivating(true);
|
||||
|
||||
try {
|
||||
const result = await setEnterpriseKeyMutation({
|
||||
variables: { enterpriseKey: enterpriseKey.trim() },
|
||||
});
|
||||
|
||||
if (result.data?.setEnterpriseKey.isValid === true) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Enterprise license activated successfully`,
|
||||
});
|
||||
setEnterpriseKey('');
|
||||
const { data: statusData } = await fetchSubscriptionStatus();
|
||||
|
||||
setSubscriptionStatus(statusData?.enterpriseSubscriptionStatus ?? null);
|
||||
await loadCurrentUser();
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to activate enterprise license. Please check your key or contact support.`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const subCode =
|
||||
error instanceof ApolloError
|
||||
? error.graphQLErrors?.[0]?.extensions?.subCode
|
||||
: undefined;
|
||||
|
||||
if (subCode === 'CONFIG_VARIABLES_IN_DB_DISABLED') {
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error as ApolloError,
|
||||
options: { duration: 10000 },
|
||||
});
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error activating enterprise license`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
}, [
|
||||
enterpriseKey,
|
||||
setEnterpriseKeyMutation,
|
||||
enqueueErrorSnackBar,
|
||||
enqueueSuccessSnackBar,
|
||||
fetchSubscriptionStatus,
|
||||
loadCurrentUser,
|
||||
t,
|
||||
]);
|
||||
|
||||
const returnUrlPath = isAdminPanelTab
|
||||
? getSettingsPath(SettingsPath.AdminPanelEnterprise)
|
||||
: getSettingsPath(SettingsPath.Enterprise);
|
||||
|
||||
const openBillingPortal = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await fetchPortalSession({
|
||||
variables: { returnUrlPath },
|
||||
});
|
||||
|
||||
const portalUrl = data?.enterprisePortalSession;
|
||||
|
||||
if (portalUrl !== null && portalUrl !== undefined) {
|
||||
window.open(portalUrl, '_blank', 'noopener');
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not open billing portal. Please check your enterprise key is present, or contact support.`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error opening billing portal`,
|
||||
});
|
||||
}
|
||||
}, [fetchPortalSession, enqueueErrorSnackBar, t, returnUrlPath]);
|
||||
|
||||
const openCheckoutModal = useCallback(() => {
|
||||
openModal(ENTERPRISE_PLAN_MODAL_ID);
|
||||
}, [openModal]);
|
||||
|
||||
const handleRefreshValidityToken = useCallback(async () => {
|
||||
setIsRefreshingToken(true);
|
||||
|
||||
try {
|
||||
const { data } = await refreshValidityTokenMutation();
|
||||
|
||||
if (data?.refreshEnterpriseValidityToken === true) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Validity token refreshed successfully`,
|
||||
});
|
||||
await loadCurrentUser();
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not refresh validity token. Please contact support.`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error refreshing validity token. Please contact support.`,
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshingToken(false);
|
||||
}
|
||||
}, [
|
||||
refreshValidityTokenMutation,
|
||||
enqueueSuccessSnackBar,
|
||||
enqueueErrorSnackBar,
|
||||
loadCurrentUser,
|
||||
t,
|
||||
]);
|
||||
|
||||
const activateKeySection = (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Activate Enterprise Key`}
|
||||
description={t`Paste your enterprise key below to activate`}
|
||||
/>
|
||||
<StyledInputContainer>
|
||||
<StyledInputWrapper>
|
||||
<SettingsTextInput
|
||||
instanceId="enterprise-key-input"
|
||||
value={enterpriseKey}
|
||||
onChange={(value) => setEnterpriseKey(value)}
|
||||
placeholder={t`Paste your enterprise key here`}
|
||||
fullWidth
|
||||
onInputEnter={handleActivate}
|
||||
/>
|
||||
</StyledInputWrapper>
|
||||
<StyledActivateButtonWrapper>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title={isActivating ? t`Activating...` : t`Activate`}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
onClick={handleActivate}
|
||||
disabled={isActivating || !enterpriseKey.trim()}
|
||||
/>
|
||||
</StyledActivateButtonWrapper>
|
||||
</StyledInputContainer>
|
||||
</Section>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isStatusLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasOrphanedValidityToken) {
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Enterprise License`}
|
||||
description={t`Your enterprise features are active but your enterprise key is missing or invalid. This may be expected, but if not, please set a valid signed enterprise key to manage your subscription, or contact support.`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title={t`Get Enterprise Key`}
|
||||
variant="secondary"
|
||||
onClick={openCheckoutModal}
|
||||
/>
|
||||
</Section>
|
||||
{activateKeySection}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasSignedEnterpriseKey) {
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Get Enterprise`}
|
||||
description={t`Unlock enterprise features like SSO, row-level security, and audit logs.`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title={t`Get Enterprise Key`}
|
||||
variant="secondary"
|
||||
onClick={openCheckoutModal}
|
||||
/>
|
||||
</Section>
|
||||
{activateKeySection}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSubscriptionActiveOrTrialing && !hasValidityToken) {
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Enterprise License`}
|
||||
description={t`Your subscription is active but your validity token is invalid or has expired. Try reloading it or contact support.`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title={
|
||||
isRefreshingToken ? t`Reloading...` : t`Reload validity token`
|
||||
}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
onClick={handleRefreshValidityToken}
|
||||
disabled={isRefreshingToken}
|
||||
/>
|
||||
<StyledSpacer />
|
||||
<SubscriptionInfoContainer>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Status`}
|
||||
Icon={IconCheck}
|
||||
currentValue={
|
||||
<StyledStatusContainer>
|
||||
<StyledStatusDot isActive={true} />
|
||||
{stripeStatus === 'trialing' ? (
|
||||
<Trans>Trial</Trans>
|
||||
) : (
|
||||
<Trans>Active</Trans>
|
||||
)}
|
||||
</StyledStatusContainer>
|
||||
}
|
||||
/>
|
||||
{licensee && (
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Licensee`}
|
||||
Icon={IconUser}
|
||||
currentValue={licensee}
|
||||
/>
|
||||
)}
|
||||
{expiresAt && (
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Valid until`}
|
||||
Icon={IconCalendarRepeat}
|
||||
currentValue={new Date(expiresAt).toLocaleDateString()}
|
||||
/>
|
||||
)}
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage billing information`}
|
||||
description={t`Edit payment method, see your invoices and more`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCreditCard}
|
||||
title={t`View billing details`}
|
||||
variant="secondary"
|
||||
onClick={openBillingPortal}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSubscriptionActiveOrTrialing) {
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Enterprise License`}
|
||||
description={
|
||||
isCancelScheduled
|
||||
? t`Your subscription is scheduled for cancellation`
|
||||
: t`Your enterprise features are active`
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Status`}
|
||||
Icon={IconCheck}
|
||||
currentValue={
|
||||
<StyledStatusContainer>
|
||||
<StyledStatusDot isActive={!isCancelScheduled} />
|
||||
{isCancelScheduled ? (
|
||||
<Trans>Cancelling</Trans>
|
||||
) : stripeStatus === 'trialing' ? (
|
||||
<Trans>Trial</Trans>
|
||||
) : (
|
||||
<Trans>Active</Trans>
|
||||
)}
|
||||
</StyledStatusContainer>
|
||||
}
|
||||
/>
|
||||
{licensee && (
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Licensee`}
|
||||
Icon={IconUser}
|
||||
currentValue={licensee}
|
||||
/>
|
||||
)}
|
||||
{expiresAt && (
|
||||
<SubscriptionInfoRowContainer
|
||||
label={isCancelScheduled ? t`Cancels on` : t`Valid until`}
|
||||
Icon={IconCalendarRepeat}
|
||||
currentValue={new Date(expiresAt).toLocaleDateString()}
|
||||
/>
|
||||
)}
|
||||
</SubscriptionInfoContainer>
|
||||
{cancellationMessage && (
|
||||
<StyledCancellationNotice>
|
||||
{cancellationMessage}
|
||||
</StyledCancellationNotice>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage billing information`}
|
||||
description={t`Edit payment method, see your invoices and more`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCreditCard}
|
||||
title={t`View billing details`}
|
||||
variant="secondary"
|
||||
onClick={openBillingPortal}
|
||||
/>
|
||||
</Section>
|
||||
{!isCancelScheduled && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Cancel your subscription`}
|
||||
description={t`Your enterprise features will be disabled`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCircleX}
|
||||
title={t`Cancel Plan`}
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
onClick={openBillingPortal}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCanceled) {
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Enterprise License`}
|
||||
description={t`Your enterprise subscription has been canceled.`}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Status`}
|
||||
Icon={IconCheck}
|
||||
currentValue={
|
||||
<StyledStatusContainer>
|
||||
<StyledStatusDot isActive={false} />
|
||||
<Trans>Canceled</Trans>
|
||||
</StyledStatusContainer>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Billing history`}
|
||||
Icon={IconCreditCard}
|
||||
currentValue={
|
||||
<Button
|
||||
title={t`View invoices`}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={openBillingPortal}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Get Enterprise`}
|
||||
description={t`Start a new enterprise subscription to re-enable enterprise features.`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title={t`Get Enterprise Key`}
|
||||
variant="secondary"
|
||||
onClick={openCheckoutModal}
|
||||
/>
|
||||
</Section>
|
||||
{activateKeySection}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPastDue) {
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Enterprise License`}
|
||||
description={t`There is a payment issue with your subscription. Please update your payment method.`}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Status`}
|
||||
Icon={IconCheck}
|
||||
currentValue={
|
||||
<StyledStatusContainer>
|
||||
<StyledStatusDot isActive={false} />
|
||||
<Trans>Payment issue</Trans>
|
||||
</StyledStatusContainer>
|
||||
}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Update payment method`}
|
||||
description={t`Fix the payment issue to keep your enterprise features active.`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCreditCard}
|
||||
title={t`Go to billing portal`}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
onClick={openBillingPortal}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isIncomplete) {
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Enterprise License`}
|
||||
description={t`Your subscription setup was not completed.`}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<SubscriptionInfoRowContainer
|
||||
label={t`Status`}
|
||||
Icon={IconCheck}
|
||||
currentValue={
|
||||
<StyledStatusContainer>
|
||||
<StyledStatusDot isActive={false} />
|
||||
<Trans>Incomplete</Trans>
|
||||
</StyledStatusContainer>
|
||||
}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Get Enterprise`}
|
||||
description={t`Start a new enterprise subscription.`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title={t`Get Enterprise Key`}
|
||||
variant="secondary"
|
||||
onClick={openCheckoutModal}
|
||||
/>
|
||||
</Section>
|
||||
{activateKeySection}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Enterprise License`}
|
||||
description={(() => {
|
||||
const statusLabel = stripeStatus ?? 'unknown';
|
||||
|
||||
return t`Your subscription status is: ${statusLabel}`;
|
||||
})()}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconCreditCard}
|
||||
title={t`Go to billing portal`}
|
||||
variant="secondary"
|
||||
onClick={openBillingPortal}
|
||||
/>
|
||||
</Section>
|
||||
{activateKeySection}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const innerContent = (
|
||||
<>
|
||||
<EnterprisePlanModal />
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isAdminPanelTab) {
|
||||
return innerContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Enterprise`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{ children: <Trans>Enterprise</Trans> },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>{innerContent}</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
@@ -68,6 +68,8 @@ export const mockCurrentWorkspace = {
|
||||
allowImpersonation: true,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
hasValidEnterpriseKey: false,
|
||||
hasValidSignedEnterpriseKey: false,
|
||||
hasValidEnterpriseValidityToken: false,
|
||||
isGoogleAuthEnabled: true,
|
||||
isPasswordAuthEnabled: true,
|
||||
isMicrosoftAuthEnabled: false,
|
||||
|
||||
@@ -805,6 +805,8 @@ type Workspace {
|
||||
currentBillingSubscription: BillingSubscription
|
||||
billingEntitlements: [BillingEntitlement!]!
|
||||
hasValidEnterpriseKey: Boolean!
|
||||
hasValidSignedEnterpriseKey: Boolean!
|
||||
hasValidEnterpriseValidityToken: Boolean!
|
||||
workspaceUrls: WorkspaceUrls!
|
||||
workspaceCustomApplicationId: String!
|
||||
}
|
||||
@@ -1454,6 +1456,22 @@ type BillingUpdate {
|
||||
billingSubscriptions: [BillingSubscription!]!
|
||||
}
|
||||
|
||||
type EnterpriseLicenseInfoDTO {
|
||||
isValid: Boolean!
|
||||
licensee: String
|
||||
expiresAt: DateTime
|
||||
subscriptionId: String
|
||||
}
|
||||
|
||||
type EnterpriseSubscriptionStatusDTO {
|
||||
status: String!
|
||||
licensee: String
|
||||
expiresAt: DateTime
|
||||
cancelAt: DateTime
|
||||
currentPeriodEnd: DateTime
|
||||
isCancellationScheduled: Boolean!
|
||||
}
|
||||
|
||||
type OnboardingStepSuccess {
|
||||
"""Boolean that confirms query was dispatched"""
|
||||
success: Boolean!
|
||||
@@ -2859,6 +2877,9 @@ type Query {
|
||||
billingPortalSession(returnUrlPath: String): BillingSession!
|
||||
listPlans: [BillingPlan!]!
|
||||
getMeteredProductsUsage: [BillingMeteredProductUsage!]!
|
||||
enterprisePortalSession(returnUrlPath: String): String
|
||||
enterpriseCheckoutSession(billingInterval: String): String
|
||||
enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO
|
||||
navigationMenuItems: [NavigationMenuItem!]!
|
||||
navigationMenuItem(id: UUID!): NavigationMenuItem
|
||||
apiKeys: [ApiKey!]!
|
||||
@@ -3119,6 +3140,8 @@ type Mutation {
|
||||
setMeteredSubscriptionPrice(priceId: String!): BillingUpdate!
|
||||
endSubscriptionTrialPeriod: BillingEndTrialPeriod!
|
||||
cancelSwitchMeteredPrice: BillingUpdate!
|
||||
refreshEnterpriseValidityToken: Boolean!
|
||||
setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO!
|
||||
createNavigationMenuItem(input: CreateNavigationMenuItemInput!): NavigationMenuItem!
|
||||
updateNavigationMenuItem(input: UpdateOneNavigationMenuItemInput!): NavigationMenuItem!
|
||||
deleteNavigationMenuItem(id: UUID!): NavigationMenuItem!
|
||||
|
||||
@@ -589,6 +589,8 @@ export interface Workspace {
|
||||
currentBillingSubscription?: BillingSubscription
|
||||
billingEntitlements: BillingEntitlement[]
|
||||
hasValidEnterpriseKey: Scalars['Boolean']
|
||||
hasValidSignedEnterpriseKey: Scalars['Boolean']
|
||||
hasValidEnterpriseValidityToken: Scalars['Boolean']
|
||||
workspaceUrls: WorkspaceUrls
|
||||
workspaceCustomApplicationId: Scalars['String']
|
||||
__typename: 'Workspace'
|
||||
@@ -1170,6 +1172,24 @@ export interface BillingUpdate {
|
||||
__typename: 'BillingUpdate'
|
||||
}
|
||||
|
||||
export interface EnterpriseLicenseInfoDTO {
|
||||
isValid: Scalars['Boolean']
|
||||
licensee?: Scalars['String']
|
||||
expiresAt?: Scalars['DateTime']
|
||||
subscriptionId?: Scalars['String']
|
||||
__typename: 'EnterpriseLicenseInfoDTO'
|
||||
}
|
||||
|
||||
export interface EnterpriseSubscriptionStatusDTO {
|
||||
status: Scalars['String']
|
||||
licensee?: Scalars['String']
|
||||
expiresAt?: Scalars['DateTime']
|
||||
cancelAt?: Scalars['DateTime']
|
||||
currentPeriodEnd?: Scalars['DateTime']
|
||||
isCancellationScheduled: Scalars['Boolean']
|
||||
__typename: 'EnterpriseSubscriptionStatusDTO'
|
||||
}
|
||||
|
||||
export interface OnboardingStepSuccess {
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success: Scalars['Boolean']
|
||||
@@ -2542,6 +2562,9 @@ export interface Query {
|
||||
billingPortalSession: BillingSession
|
||||
listPlans: BillingPlan[]
|
||||
getMeteredProductsUsage: BillingMeteredProductUsage[]
|
||||
enterprisePortalSession?: Scalars['String']
|
||||
enterpriseCheckoutSession?: Scalars['String']
|
||||
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO
|
||||
navigationMenuItems: NavigationMenuItem[]
|
||||
navigationMenuItem?: NavigationMenuItem
|
||||
apiKeys: ApiKey[]
|
||||
@@ -2692,6 +2715,8 @@ export interface Mutation {
|
||||
setMeteredSubscriptionPrice: BillingUpdate
|
||||
endSubscriptionTrialPeriod: BillingEndTrialPeriod
|
||||
cancelSwitchMeteredPrice: BillingUpdate
|
||||
refreshEnterpriseValidityToken: Scalars['Boolean']
|
||||
setEnterpriseKey: EnterpriseLicenseInfoDTO
|
||||
createNavigationMenuItem: NavigationMenuItem
|
||||
updateNavigationMenuItem: NavigationMenuItem
|
||||
deleteNavigationMenuItem: NavigationMenuItem
|
||||
@@ -3432,6 +3457,8 @@ export interface WorkspaceGenqlSelection{
|
||||
currentBillingSubscription?: BillingSubscriptionGenqlSelection
|
||||
billingEntitlements?: BillingEntitlementGenqlSelection
|
||||
hasValidEnterpriseKey?: boolean | number
|
||||
hasValidSignedEnterpriseKey?: boolean | number
|
||||
hasValidEnterpriseValidityToken?: boolean | number
|
||||
workspaceUrls?: WorkspaceUrlsGenqlSelection
|
||||
workspaceCustomApplicationId?: boolean | number
|
||||
__typename?: boolean | number
|
||||
@@ -4042,6 +4069,26 @@ export interface BillingUpdateGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface EnterpriseLicenseInfoDTOGenqlSelection{
|
||||
isValid?: boolean | number
|
||||
licensee?: boolean | number
|
||||
expiresAt?: boolean | number
|
||||
subscriptionId?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
|
||||
status?: boolean | number
|
||||
licensee?: boolean | number
|
||||
expiresAt?: boolean | number
|
||||
cancelAt?: boolean | number
|
||||
currentPeriodEnd?: boolean | number
|
||||
isCancellationScheduled?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface OnboardingStepSuccessGenqlSelection{
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success?: boolean | number
|
||||
@@ -5530,6 +5577,9 @@ export interface QueryGenqlSelection{
|
||||
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} })
|
||||
listPlans?: BillingPlanGenqlSelection
|
||||
getMeteredProductsUsage?: BillingMeteredProductUsageGenqlSelection
|
||||
enterprisePortalSession?: { __args: {returnUrlPath?: (Scalars['String'] | null)} } | boolean | number
|
||||
enterpriseCheckoutSession?: { __args: {billingInterval?: (Scalars['String'] | null)} } | boolean | number
|
||||
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection
|
||||
navigationMenuItems?: NavigationMenuItemGenqlSelection
|
||||
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
apiKeys?: ApiKeyGenqlSelection
|
||||
@@ -5711,6 +5761,8 @@ export interface MutationGenqlSelection{
|
||||
setMeteredSubscriptionPrice?: (BillingUpdateGenqlSelection & { __args: {priceId: Scalars['String']} })
|
||||
endSubscriptionTrialPeriod?: BillingEndTrialPeriodGenqlSelection
|
||||
cancelSwitchMeteredPrice?: BillingUpdateGenqlSelection
|
||||
refreshEnterpriseValidityToken?: boolean | number
|
||||
setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} })
|
||||
createNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: CreateNavigationMenuItemInput} })
|
||||
updateNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: UpdateOneNavigationMenuItemInput} })
|
||||
deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
@@ -6883,6 +6935,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
|
||||
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
|
||||
return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO']
|
||||
export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"')
|
||||
return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const OnboardingStepSuccess_possibleTypes: string[] = ['OnboardingStepSuccess']
|
||||
export const isOnboardingStepSuccess = (obj?: { __typename?: any } | null): obj is OnboardingStepSuccess => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isOnboardingStepSuccess"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,14 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
|
||||
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
|
||||
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
|
||||
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
|
||||
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
|
||||
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
|
||||
import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { TrashCleanupCronCommand } from 'src/engine/trash-cleanup/commands/trash-cleanup.cron.command';
|
||||
import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command';
|
||||
import { CleanSuspendedWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.cron.command';
|
||||
@@ -54,6 +56,8 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
private readonly cleanOnboardingWorkspacesCronCommand: CleanOnboardingWorkspacesCronCommand,
|
||||
private readonly trashCleanupCronCommand: TrashCleanupCronCommand,
|
||||
private readonly eventLogCleanupCronCommand: EventLogCleanupCronCommand,
|
||||
private readonly enterpriseKeyValidationCronCommand: EnterpriseKeyValidationCronCommand,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
|
||||
private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand,
|
||||
) {
|
||||
@@ -148,6 +152,10 @@ export class CronRegisterAllCommand extends CommandRunner {
|
||||
name: 'ApplicationVersionCheck',
|
||||
command: this.applicationVersionCheckCronCommand,
|
||||
},
|
||||
{
|
||||
name: 'EnterpriseKeyValidation',
|
||||
command: this.enterpriseKeyValidationCronCommand,
|
||||
},
|
||||
];
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
@@ -9,12 +9,15 @@ import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-versi
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
|
||||
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
|
||||
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
|
||||
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
|
||||
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
@@ -57,6 +60,8 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
TrashCleanupModule,
|
||||
PublicDomainModule,
|
||||
EventLogCleanupModule,
|
||||
EnterpriseModule,
|
||||
TwentyConfigModule,
|
||||
MarketplaceModule,
|
||||
ApplicationUpgradeModule,
|
||||
],
|
||||
@@ -65,6 +70,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
ConfirmationQuestion,
|
||||
CronRegisterAllCommand,
|
||||
ListOrphanedWorkspaceEntitiesCommand,
|
||||
EnterpriseKeyValidationCronCommand,
|
||||
GenerateApiKeyCommand,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ export enum AppTokenType {
|
||||
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
|
||||
InvitationToken = 'INVITATION_TOKEN',
|
||||
EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
|
||||
EnterpriseValidityToken = 'ENTERPRISE_VALIDITY_TOKEN',
|
||||
}
|
||||
|
||||
@Entity({ name: 'appToken', schema: 'core' })
|
||||
|
||||
@@ -29,6 +29,7 @@ export const AuthExceptionCode = appendCommonExceptionCode({
|
||||
GOOGLE_API_AUTH_DISABLED: 'GOOGLE_API_AUTH_DISABLED',
|
||||
MICROSOFT_API_AUTH_DISABLED: 'MICROSOFT_API_AUTH_DISABLED',
|
||||
MISSING_ENVIRONMENT_VARIABLE: 'MISSING_ENVIRONMENT_VARIABLE',
|
||||
ENTERPRISE_VALIDITY_TOKEN_NOT_VALID: 'ENTERPRISE_VALIDITY_TOKEN_NOT_VALID',
|
||||
INVALID_JWT_TOKEN_TYPE: 'INVALID_JWT_TOKEN_TYPE',
|
||||
TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
|
||||
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
|
||||
@@ -80,6 +81,8 @@ const getAuthExceptionUserFriendlyMessage = (
|
||||
return msg`Two-factor authentication verification is required.`;
|
||||
case AuthExceptionCode.USER_ALREADY_EXISTS:
|
||||
return msg`A user with this email already exists.`;
|
||||
case AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID:
|
||||
return msg`Enterprise validity token is not valid.`;
|
||||
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
case AuthExceptionCode.INVALID_DATA:
|
||||
case AuthExceptionCode.CLIENT_NOT_FOUND:
|
||||
|
||||
@@ -37,6 +37,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
|
||||
import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
@@ -120,6 +121,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
ApplicationModule,
|
||||
WorkspaceCacheModule,
|
||||
SecureHttpClientModule,
|
||||
EnterpriseModule,
|
||||
FileModule,
|
||||
],
|
||||
controllers: [
|
||||
|
||||
+5
-5
@@ -10,22 +10,22 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class EnterpriseFeaturesEnabledGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
try {
|
||||
if (!this.twentyConfigService.get('ENTERPRISE_KEY')) {
|
||||
if (!this.enterprisePlanService.isValid()) {
|
||||
throw new AuthException(
|
||||
'Enterprise key missing',
|
||||
AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE,
|
||||
'Enterprise features are not enabled',
|
||||
AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
|
||||
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
|
||||
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
|
||||
case AuthExceptionCode.USER_ALREADY_EXISTS:
|
||||
case AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID:
|
||||
throw new ForbiddenError(exception);
|
||||
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
|
||||
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
|
||||
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
|
||||
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
|
||||
case AuthExceptionCode.USER_ALREADY_EXISTS:
|
||||
case AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID:
|
||||
return 403;
|
||||
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
|
||||
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
|
||||
|
||||
@@ -32,6 +32,7 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
|
||||
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
|
||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
|
||||
@@ -66,6 +67,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
]),
|
||||
DataSourceModule,
|
||||
MetricsModule,
|
||||
EnterpriseModule,
|
||||
],
|
||||
providers: [
|
||||
BillingSubscriptionService,
|
||||
|
||||
+4
-4
@@ -38,6 +38,7 @@ import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/se
|
||||
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@@ -61,6 +62,7 @@ export class BillingSubscriptionService {
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingSubscriptionEntity>,
|
||||
private readonly meteredCreditService: MeteredCreditService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
) {}
|
||||
|
||||
async getBillingSubscriptions(workspaceId: string) {
|
||||
@@ -180,9 +182,7 @@ export class BillingSubscriptionService {
|
||||
workspaceId: string,
|
||||
): Promise<BillingEntitlementDTO[]> {
|
||||
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
|
||||
const hasValidEnterpriseKey = isDefined(
|
||||
this.twentyConfigService.get('ENTERPRISE_KEY'),
|
||||
);
|
||||
const hasValidEnterprisePlan = this.enterprisePlanService.isValid();
|
||||
|
||||
const entitlements = isBillingEnabled
|
||||
? await this.billingEntitlementRepository.find({
|
||||
@@ -202,7 +202,7 @@ export class BillingSubscriptionService {
|
||||
return Object.values(BillingEntitlementKey).map((key) => ({
|
||||
key,
|
||||
value:
|
||||
hasValidEnterpriseKey &&
|
||||
hasValidEnterprisePlan &&
|
||||
(!isBillingEnabled || (entitlementsByKey[key]?.value ?? false)),
|
||||
}));
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Run daily at 4 AM UTC
|
||||
export const ENTERPRISE_KEY_VALIDATION_CRON_PATTERN = '0 4 * * *';
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// RS256 public key for verifying enterprise license JWTs signed by twenty.com
|
||||
// The corresponding private key is held exclusively by twenty.com
|
||||
export const ENTERPRISE_JWT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl12Me8NXrQhsgBTr8slx
|
||||
2lTZNJCLwWCIs3zRWZzuHelUgNj2wFEM7R7wx0v/OxQHoXzXqAbgEu67HHNXTAnA
|
||||
gcYGzjSqa6o8NZHqUrzjOgvP0Ck8EQYxNYrxHAiDUChMHsFNYvcx/savm6Pn1sTL
|
||||
gcYnGuuuAYdsV0L78N5WsdbSfyAPyPv5ULYMSci7OLGUBlIa55da9Qwmie1HC+J4
|
||||
MtSSnw9o9OzR1ekw9JxQdho+Rj1mQ9BuvBplGNLabolFZweYdYEyqXReRbqMmNz/
|
||||
EuZs6PhKiH6l3sXY6kocC0ZV25rFhHgOChVA91BE8a+Wj0MtGGI/UL/b21G3zsr7
|
||||
6wIDAQAB
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
// Dev-only
|
||||
export const ENTERPRISE_JWT_DEV_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsHEWc84t399KlYyhRkBO
|
||||
QCXE8/HE/TKvMk35ccoUeZXnBNhuGpWcHuM6+6ekHiToT239hkBy+Bk/Ybd6wVrs
|
||||
Vn0Vc0KarRsmeOrJu+sVREL5AUWt0gitpBoNeBzdfgW8dzdyVKDSCUNvXzEOQKkT
|
||||
+tmDYhvSs8VSmzer2juSaj4xQ35X/sM+Ea3IHFx8mf9d6fMAJ5u8AE0fAFD9XUbe
|
||||
Dmj1SnUz4yy11EeY48+wTguk9WBFjOsA/1Dnc6jL/MN8zH77xRlIs/iPjBriPhCN
|
||||
njn0rDVHHdat+3NqKlSbFcQPVzYicxDRXaakJ2IEJhxpEoOFWHJ9bNQKudRqfaoF
|
||||
BQIDAQAB
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
// signed with ENTERPRISE_JWT_DEV_PRIVATE_KEY (expires 2125)
|
||||
export const ENTERPRISE_DEV_VALIDITY_TOKEN =
|
||||
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXYtc3Vic2NyaXB0aW9uLWlkIiwic3RhdHVzIjoidmFsaWQiLCJpYXQiOjE3NzMzMDg4MzMsImV4cCI6NDg5NzUxMTIzM30.qhfrW_SV2Y86fWtWXsALlAVhxmMxylUUIefN0fki10Q2NTGGqFVXZrNn2WacJY37yq3m5y4WgwZw34ua6E0ff_YUXsrlY5OHJWHT9DMqKCRn-JujHJnnYp3VHLncy5CvxH5r9mfPFp-5AWe1pYeR1T63sTiejH3sfDrNE357SB7KVti8LCcnsJxEtXB2tRnvyvdun7A-GKoKYEIam-16ZRKKFs6GaWo8ObHdfm8yBt6uK4DZSGPWb644QyWh9FtDxbzJ0ti54DuHSlErLgIp1NNEsMA0MK7zFY7StRaOdt72rxE1ZHwN7e6HhweTU4ORVUPfYkjDFLB2fF7Pa7Kvdg';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
const DAYS_TO_MS = (days: number) => days * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const ENTERPRISE_VALIDITY_TOKEN_DEFAULT_EXPIRATION_MS = DAYS_TO_MS(30);
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { ENTERPRISE_KEY_VALIDATION_CRON_PATTERN } from 'src/engine/core-modules/enterprise/constants/enterprise-key-validation-cron-pattern.constant';
|
||||
import { EnterpriseKeyValidationCronJob } from 'src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
@Command({
|
||||
name: 'cron:enterprise-key-validation',
|
||||
description:
|
||||
'Starts a daily cron job to refresh the enterprise validity token',
|
||||
})
|
||||
export class EnterpriseKeyValidationCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: EnterpriseKeyValidationCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: ENTERPRISE_KEY_VALIDATION_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ENTERPRISE_KEY_VALIDATION_CRON_PATTERN } from 'src/engine/core-modules/enterprise/constants/enterprise-key-validation-cron-pattern.constant';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class EnterpriseKeyValidationCronJob {
|
||||
private readonly logger = new Logger(EnterpriseKeyValidationCronJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
@Process(EnterpriseKeyValidationCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
EnterpriseKeyValidationCronJob.name,
|
||||
ENTERPRISE_KEY_VALIDATION_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
this.logger.log(
|
||||
'Starting enterprise validity token refresh and seat report...',
|
||||
);
|
||||
|
||||
const refreshSuccess =
|
||||
await this.enterprisePlanService.refreshValidityToken();
|
||||
|
||||
if (refreshSuccess) {
|
||||
this.logger.log('Enterprise validity token refreshed successfully');
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'Enterprise validity token refresh did not succeed. ' +
|
||||
'Existing validity token will continue to work until expiration.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const seatCount = await this.getActiveUserWorkspaceCount();
|
||||
|
||||
const reportSuccess =
|
||||
await this.enterprisePlanService.reportSeats(seatCount);
|
||||
|
||||
if (reportSuccess) {
|
||||
this.logger.log(`Reported ${seatCount} seats to enterprise API`);
|
||||
} else {
|
||||
this.logger.warn('Seat report did not succeed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to get seat count or report: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getActiveUserWorkspaceCount(): Promise<number> {
|
||||
const count = await this.userWorkspaceRepository.count({
|
||||
where: { deletedAt: IsNull() },
|
||||
});
|
||||
|
||||
return Math.max(1, count);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class EnterpriseLicenseInfoDTO {
|
||||
@Field(() => Boolean)
|
||||
isValid: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
licensee: string | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt: Date | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
subscriptionId: string | null;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class EnterpriseSubscriptionStatusDTO {
|
||||
@Field(() => String)
|
||||
status: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
licensee: string | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
cancelAt: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
currentPeriodEnd: Date | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isCancellationScheduled: boolean;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class SetEnterpriseKeyInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
enterpriseKey: string;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Catch, type ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
EnterpriseException,
|
||||
EnterpriseExceptionCode,
|
||||
} from 'src/engine/core-modules/enterprise/enterprise.exception';
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(EnterpriseException)
|
||||
export class EnterpriseExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: EnterpriseException) {
|
||||
switch (exception.code) {
|
||||
case EnterpriseExceptionCode.INVALID_ENTERPRISE_KEY:
|
||||
case EnterpriseExceptionCode.CONFIG_VARIABLES_IN_DB_DISABLED:
|
||||
throw new UserInputError(exception);
|
||||
default: {
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum EnterpriseExceptionCode {
|
||||
INVALID_ENTERPRISE_KEY = 'INVALID_ENTERPRISE_KEY',
|
||||
CONFIG_VARIABLES_IN_DB_DISABLED = 'CONFIG_VARIABLES_IN_DB_DISABLED',
|
||||
}
|
||||
|
||||
const getEnterpriseExceptionUserFriendlyMessage = (
|
||||
code: EnterpriseExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case EnterpriseExceptionCode.INVALID_ENTERPRISE_KEY:
|
||||
return msg`Invalid enterprise key.`;
|
||||
case EnterpriseExceptionCode.CONFIG_VARIABLES_IN_DB_DISABLED:
|
||||
return msg`IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on your server. Please add ENTERPRISE_KEY to your .env file manually.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class EnterpriseException extends CustomException<EnterpriseExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: EnterpriseExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ?? getEnterpriseExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { EnterpriseKeyValidationCronJob } from 'src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job';
|
||||
import { EnterpriseResolver } from 'src/engine/core-modules/enterprise/enterprise.resolver';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity, AppTokenEntity]),
|
||||
],
|
||||
providers: [
|
||||
EnterprisePlanService,
|
||||
EnterpriseKeyValidationCronJob,
|
||||
EnterpriseResolver,
|
||||
],
|
||||
exports: [EnterprisePlanService, EnterpriseKeyValidationCronJob],
|
||||
})
|
||||
export class EnterpriseModule {}
|
||||
@@ -0,0 +1,150 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { EnterpriseExceptionFilter } from 'src/engine/core-modules/enterprise/enterprise-exception.filter';
|
||||
import { EnterpriseLicenseInfoDTO } from 'src/engine/core-modules/enterprise/dtos/enterprise-license-info.dto';
|
||||
import { EnterpriseSubscriptionStatusDTO } from 'src/engine/core-modules/enterprise/dtos/enterprise-subscription-status.dto';
|
||||
import {
|
||||
EnterpriseException,
|
||||
EnterpriseExceptionCode,
|
||||
} from 'src/engine/core-modules/enterprise/enterprise.exception';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { ConfigVariableExceptionCode } from 'src/engine/core-modules/twenty-config/twenty-config.exception';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
|
||||
import { BillingDisabledGuard } from 'src/engine/guards/billing-disabled.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@Resolver()
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(EnterpriseExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
export class EnterpriseResolver {
|
||||
constructor(
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
private async getActiveUserWorkspaceCount(): Promise<number> {
|
||||
const count = await this.userWorkspaceRepository.count({
|
||||
where: { deletedAt: IsNull() },
|
||||
});
|
||||
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
@Query(() => String, { nullable: true })
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
BillingDisabledGuard,
|
||||
AdminPanelGuard,
|
||||
NoPermissionGuard,
|
||||
)
|
||||
async enterprisePortalSession(
|
||||
// for existing subscriptions
|
||||
@Args('returnUrlPath', { nullable: true }) returnUrlPath?: string,
|
||||
): Promise<string | null> {
|
||||
return this.enterprisePlanService.getPortalUrl(returnUrlPath ?? undefined);
|
||||
}
|
||||
|
||||
@Query(() => String, { nullable: true })
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
BillingDisabledGuard,
|
||||
AdminPanelGuard,
|
||||
NoPermissionGuard,
|
||||
)
|
||||
async enterpriseCheckoutSession(
|
||||
// for new subscriptions
|
||||
@Args('billingInterval', { nullable: true }) billingInterval?: string,
|
||||
): Promise<string | null> {
|
||||
const interval = billingInterval === 'yearly' ? 'yearly' : 'monthly';
|
||||
const seatCount = await this.getActiveUserWorkspaceCount();
|
||||
|
||||
return this.enterprisePlanService.getCheckoutUrl(interval, seatCount);
|
||||
}
|
||||
|
||||
@Query(() => EnterpriseSubscriptionStatusDTO, { nullable: true })
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
BillingDisabledGuard,
|
||||
AdminPanelGuard,
|
||||
NoPermissionGuard,
|
||||
)
|
||||
async enterpriseSubscriptionStatus(): Promise<EnterpriseSubscriptionStatusDTO | null> {
|
||||
return this.enterprisePlanService.getSubscriptionStatus();
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
BillingDisabledGuard,
|
||||
AdminPanelGuard,
|
||||
NoPermissionGuard,
|
||||
)
|
||||
async refreshEnterpriseValidityToken(): Promise<boolean> {
|
||||
return this.enterprisePlanService.refreshValidityToken();
|
||||
}
|
||||
|
||||
@Mutation(() => EnterpriseLicenseInfoDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
BillingDisabledGuard,
|
||||
AdminPanelGuard,
|
||||
NoPermissionGuard,
|
||||
)
|
||||
async setEnterpriseKey(
|
||||
@Args('enterpriseKey') enterpriseKey: string,
|
||||
): Promise<EnterpriseLicenseInfoDTO> {
|
||||
try {
|
||||
if (
|
||||
!this.enterprisePlanService.isValidEnterpriseKeyFormat(enterpriseKey)
|
||||
) {
|
||||
throw new EnterpriseException(
|
||||
'Invalid enterprise key',
|
||||
EnterpriseExceptionCode.INVALID_ENTERPRISE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
await this.enterprisePlanService.setEnterpriseKey(enterpriseKey);
|
||||
|
||||
await this.enterprisePlanService.refreshValidityToken();
|
||||
|
||||
const seatCount = await this.getActiveUserWorkspaceCount();
|
||||
|
||||
await this.enterprisePlanService.reportSeats(seatCount);
|
||||
|
||||
return await this.enterprisePlanService.getLicenseInfo();
|
||||
} catch (error) {
|
||||
if (error instanceof EnterpriseException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED
|
||||
) {
|
||||
throw new EnterpriseException(
|
||||
'IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on the server. Please add ENTERPRISE_KEY to your .env file manually.',
|
||||
EnterpriseExceptionCode.CONFIG_VARIABLES_IN_DB_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
licensee: null,
|
||||
expiresAt: null,
|
||||
subscriptionId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+806
@@ -0,0 +1,806 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import {
|
||||
ConfigVariableException,
|
||||
ConfigVariableExceptionCode,
|
||||
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const mockCryptoVerify = jest.fn();
|
||||
|
||||
jest.mock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
verify: (...args: unknown[]) => mockCryptoVerify(...args),
|
||||
}));
|
||||
|
||||
const createFakeJwt = (payload: Record<string, unknown>): string => {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: 'RS256', typ: 'JWT' }),
|
||||
).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = Buffer.from('fake-signature').toString('base64url');
|
||||
|
||||
return `${header}.${body}.${signature}`;
|
||||
};
|
||||
|
||||
const MOCK_API_URL = 'https://enterprise.example.com';
|
||||
const FUTURE_TIMESTAMP = Math.floor(Date.now() / 1000) + 3600;
|
||||
const PAST_TIMESTAMP = Math.floor(Date.now() / 1000) - 3600;
|
||||
|
||||
const MOCK_KEY_PAYLOAD = {
|
||||
sub: 'sub-123',
|
||||
licensee: 'ACME Corp',
|
||||
iat: 1000,
|
||||
};
|
||||
|
||||
const MOCK_VALIDITY_PAYLOAD = {
|
||||
sub: 'sub-123',
|
||||
status: 'valid',
|
||||
iat: 1000,
|
||||
exp: FUTURE_TIMESTAMP,
|
||||
};
|
||||
|
||||
const MOCK_EXPIRED_VALIDITY_PAYLOAD = {
|
||||
sub: 'sub-123',
|
||||
status: 'valid',
|
||||
iat: 1000,
|
||||
exp: PAST_TIMESTAMP,
|
||||
};
|
||||
|
||||
describe('EnterprisePlanService', () => {
|
||||
let service: EnterprisePlanService;
|
||||
|
||||
const configGetMock = jest.fn();
|
||||
const configSetMock = jest.fn();
|
||||
const appTokenFindOneMock = jest.fn();
|
||||
const transactionMock = jest.fn();
|
||||
const fetchMock = jest.fn();
|
||||
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
const setupEnterpriseKey = (key?: string) => {
|
||||
configGetMock.mockImplementation((configKey: string) => {
|
||||
if (configKey === 'ENTERPRISE_KEY') return key;
|
||||
if (configKey === 'ENTERPRISE_API_URL') return MOCK_API_URL;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
const setupValidState = async (
|
||||
overrides: {
|
||||
keyPayload?: Record<string, unknown>;
|
||||
validityPayload?: Record<string, unknown>;
|
||||
cryptoVerifyResult?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const {
|
||||
keyPayload = MOCK_KEY_PAYLOAD,
|
||||
validityPayload = MOCK_VALIDITY_PAYLOAD,
|
||||
cryptoVerifyResult = true,
|
||||
} = overrides;
|
||||
|
||||
const fakeKey = createFakeJwt(keyPayload);
|
||||
const fakeValidityToken = createFakeJwt(validityPayload);
|
||||
|
||||
setupEnterpriseKey(fakeKey);
|
||||
mockCryptoVerify.mockReturnValue(cryptoVerifyResult);
|
||||
appTokenFindOneMock.mockResolvedValue({ value: fakeValidityToken });
|
||||
|
||||
await service.onModuleInit();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
originalFetch = global.fetch;
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
configGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EnterprisePlanService,
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: configGetMock,
|
||||
set: configSetMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppTokenEntity),
|
||||
useValue: {
|
||||
findOne: appTokenFindOneMock,
|
||||
target: AppTokenEntity,
|
||||
manager: {
|
||||
transaction: transactionMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EnterprisePlanService>(EnterprisePlanService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('onModuleInit', () => {
|
||||
it('should populate caches when enterprise key and validity token exist', async () => {
|
||||
await setupValidState();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(true);
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing enterprise key', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle DB error when loading validity token', async () => {
|
||||
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
appTokenFindOneMock.mockRejectedValue(new Error('DB connection failed'));
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(true);
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to ENTERPRISE_VALIDITY_TOKEN config when DB has no token', async () => {
|
||||
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
const fakeValidityToken = createFakeJwt(MOCK_VALIDITY_PAYLOAD);
|
||||
|
||||
configGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'ENTERPRISE_KEY') return fakeKey;
|
||||
if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL;
|
||||
if (key === 'ENTERPRISE_VALIDITY_TOKEN') return fakeValidityToken;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer DB token over ENTERPRISE_VALIDITY_TOKEN config', async () => {
|
||||
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
const dbToken = createFakeJwt(MOCK_VALIDITY_PAYLOAD);
|
||||
const envToken = createFakeJwt(MOCK_EXPIRED_VALIDITY_PAYLOAD);
|
||||
|
||||
configGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'ENTERPRISE_KEY') return fakeKey;
|
||||
if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL;
|
||||
if (key === 'ENTERPRISE_VALIDITY_TOKEN') return envToken;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
appTokenFindOneMock.mockResolvedValue({ value: dbToken });
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject validity token with non-valid status', async () => {
|
||||
const invalidStatusPayload = {
|
||||
...MOCK_VALIDITY_PAYLOAD,
|
||||
status: 'revoked',
|
||||
};
|
||||
|
||||
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
appTokenFindOneMock.mockResolvedValue({
|
||||
value: createFakeJwt(invalidStatusPayload),
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasValidSignedEnterpriseKey', () => {
|
||||
it('should return false when no enterprise key is configured', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when key has valid signature', async () => {
|
||||
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when key has invalid signature', async () => {
|
||||
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
|
||||
mockCryptoVerify.mockReturnValue(false);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when key is not a valid JWT format', async () => {
|
||||
setupEnterpriseKey('not-a-jwt');
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasValidEnterpriseValidityToken', () => {
|
||||
it('should return false when no validity token exists', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when validity token is valid and not expired', async () => {
|
||||
await setupValidState();
|
||||
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when validity token is expired', async () => {
|
||||
await setupValidState({
|
||||
validityPayload: MOCK_EXPIRED_VALIDITY_PAYLOAD,
|
||||
});
|
||||
|
||||
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasValidEnterpriseKey', () => {
|
||||
it('should return true when signed enterprise key is valid', async () => {
|
||||
await setupValidState();
|
||||
|
||||
expect(service.hasValidEnterpriseKey()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true with legacy unsigned key as fallback', async () => {
|
||||
setupEnterpriseKey('some-legacy-key');
|
||||
mockCryptoVerify.mockReturnValue(false);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
|
||||
expect(service.hasValidEnterpriseKey()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no key is configured', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.hasValidEnterpriseKey()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid', () => {
|
||||
it('should return true when validity token is valid', async () => {
|
||||
await setupValidState();
|
||||
|
||||
expect(service.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true with legacy key as fallback', async () => {
|
||||
setupEnterpriseKey('some-legacy-key');
|
||||
mockCryptoVerify.mockReturnValue(false);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no key or token exists', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(service.isValid()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEnterpriseKeyFormat', () => {
|
||||
it('should return true for valid JWT format', () => {
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
const validKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
|
||||
expect(service.isValidEnterpriseKeyFormat(validKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid JWT format', () => {
|
||||
expect(service.isValidEnterpriseKeyFormat('not-a-jwt')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when signature verification fails', () => {
|
||||
mockCryptoVerify.mockReturnValue(false);
|
||||
const invalidKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
|
||||
expect(service.isValidEnterpriseKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLicenseInfo', () => {
|
||||
it('should return valid license info when validity token exists', async () => {
|
||||
await setupValidState();
|
||||
|
||||
const licenseInfo = await service.getLicenseInfo();
|
||||
|
||||
expect(licenseInfo).toEqual({
|
||||
isValid: true,
|
||||
licensee: 'ACME Corp',
|
||||
expiresAt: new Date(FUTURE_TIMESTAMP * 1000),
|
||||
subscriptionId: 'sub-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return expired license info when validity token is expired', async () => {
|
||||
await setupValidState({
|
||||
validityPayload: MOCK_EXPIRED_VALIDITY_PAYLOAD,
|
||||
});
|
||||
|
||||
const licenseInfo = await service.getLicenseInfo();
|
||||
|
||||
expect(licenseInfo).toEqual({
|
||||
isValid: false,
|
||||
licensee: 'ACME Corp',
|
||||
expiresAt: new Date(PAST_TIMESTAMP * 1000),
|
||||
subscriptionId: 'sub-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return legacy license info when only legacy key exists', async () => {
|
||||
setupEnterpriseKey('some-legacy-key');
|
||||
mockCryptoVerify.mockReturnValue(false);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
|
||||
const licenseInfo = await service.getLicenseInfo();
|
||||
|
||||
expect(licenseInfo).toEqual({
|
||||
isValid: true,
|
||||
licensee: null,
|
||||
expiresAt: null,
|
||||
subscriptionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return invalid license info when no key exists', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
|
||||
const licenseInfo = await service.getLicenseInfo();
|
||||
|
||||
expect(licenseInfo).toEqual({
|
||||
isValid: false,
|
||||
licensee: null,
|
||||
expiresAt: null,
|
||||
subscriptionId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnterpriseKey', () => {
|
||||
it('should set the enterprise key via config service', async () => {
|
||||
configSetMock.mockResolvedValue(undefined);
|
||||
|
||||
await service.setEnterpriseKey('new-enterprise-key');
|
||||
|
||||
expect(configSetMock).toHaveBeenCalledWith(
|
||||
'ENTERPRISE_KEY',
|
||||
'new-enterprise-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw specific error when DB config is disabled', async () => {
|
||||
configSetMock.mockRejectedValue(
|
||||
new ConfigVariableException(
|
||||
'Database config disabled',
|
||||
ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.setEnterpriseKey('key')).rejects.toThrow(
|
||||
'IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on your server',
|
||||
);
|
||||
});
|
||||
|
||||
it('should re-throw other errors', async () => {
|
||||
configSetMock.mockRejectedValue(new Error('Unexpected error'));
|
||||
|
||||
await expect(service.setEnterpriseKey('key')).rejects.toThrow(
|
||||
'Unexpected error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshValidityToken', () => {
|
||||
it('should return false when no enterprise key is configured', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
|
||||
const result = await service.refreshValidityToken();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when key is not a valid signed JWT', async () => {
|
||||
setupEnterpriseKey('not-a-valid-jwt');
|
||||
|
||||
const result = await service.refreshValidityToken();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh and return true when API call succeeds', async () => {
|
||||
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
const fakeValidityToken = createFakeJwt(MOCK_VALIDITY_PAYLOAD);
|
||||
|
||||
setupEnterpriseKey(fakeKey);
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ validityToken: fakeValidityToken }),
|
||||
});
|
||||
|
||||
transactionMock.mockImplementation(
|
||||
async (callback: (manager: Record<string, jest.Mock>) => void) => {
|
||||
await callback({
|
||||
update: jest.fn(),
|
||||
save: jest.fn(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
appTokenFindOneMock.mockResolvedValue({ value: fakeValidityToken });
|
||||
|
||||
const result = await service.refreshValidityToken();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${MOCK_API_URL}/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enterpriseKey: fakeKey }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when API returns non-OK response', async () => {
|
||||
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
|
||||
setupEnterpriseKey(fakeKey);
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ error: 'Unauthorized' }),
|
||||
});
|
||||
|
||||
const result = await service.refreshValidityToken();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when API response is missing validityToken', async () => {
|
||||
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
|
||||
setupEnterpriseKey(fakeKey);
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const result = await service.refreshValidityToken();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on network error', async () => {
|
||||
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
|
||||
|
||||
setupEnterpriseKey(fakeKey);
|
||||
mockCryptoVerify.mockReturnValue(true);
|
||||
|
||||
fetchMock.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await service.refreshValidityToken();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportSeats', () => {
|
||||
it('should return false when no enterprise key is configured', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
|
||||
const result = await service.reportSeats(10);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when key is not a valid signed JWT', async () => {
|
||||
setupEnterpriseKey('not-a-valid-jwt');
|
||||
appTokenFindOneMock.mockResolvedValue(null);
|
||||
await service.onModuleInit();
|
||||
|
||||
const result = await service.reportSeats(10);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should report seats and return true on success', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
const result = await service.reportSeats(25);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${MOCK_API_URL}/seats`,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const callBody = JSON.parse(
|
||||
(fetchMock.mock.calls[0] as [string, { body: string }])[1].body,
|
||||
);
|
||||
|
||||
expect(callBody.seatCount).toBe(25);
|
||||
});
|
||||
|
||||
it('should return false when API returns non-OK response', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const result = await service.reportSeats(10);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on network error', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const result = await service.reportSeats(10);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriptionStatus', () => {
|
||||
it('should return null when no enterprise key is configured', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
|
||||
const result = await service.getSubscriptionStatus();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return subscription status on success', async () => {
|
||||
await setupValidState();
|
||||
|
||||
const cancelAtTimestamp = Math.floor(Date.now() / 1000) + 86400;
|
||||
const periodEndTimestamp = Math.floor(Date.now() / 1000) + 2592000;
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: 'active',
|
||||
cancelAt: cancelAtTimestamp,
|
||||
currentPeriodEnd: periodEndTimestamp,
|
||||
isCancellationScheduled: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.getSubscriptionStatus();
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'active',
|
||||
licensee: 'ACME Corp',
|
||||
expiresAt: new Date(FUTURE_TIMESTAMP * 1000),
|
||||
cancelAt: new Date(cancelAtTimestamp * 1000),
|
||||
currentPeriodEnd: new Date(periodEndTimestamp * 1000),
|
||||
isCancellationScheduled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when API returns non-OK response', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const result = await service.getSubscriptionStatus();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await service.getSubscriptionStatus();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle null cancelAt and currentPeriodEnd', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: 'active',
|
||||
cancelAt: null,
|
||||
currentPeriodEnd: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.getSubscriptionStatus();
|
||||
|
||||
expect(result?.cancelAt).toBeNull();
|
||||
expect(result?.currentPeriodEnd).toBeNull();
|
||||
expect(result?.isCancellationScheduled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPortalUrl', () => {
|
||||
it('should return null when no API URL is configured', async () => {
|
||||
configGetMock.mockReturnValue(undefined);
|
||||
|
||||
const result = await service.getPortalUrl();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no valid enterprise key exists', async () => {
|
||||
setupEnterpriseKey(undefined);
|
||||
|
||||
const result = await service.getPortalUrl();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return portal URL on success', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ url: 'https://portal.example.com' }),
|
||||
});
|
||||
|
||||
const result = await service.getPortalUrl('https://return.example.com');
|
||||
|
||||
expect(result).toBe('https://portal.example.com');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${MOCK_API_URL}/portal`,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when API fails', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const result = await service.getPortalUrl();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
await setupValidState();
|
||||
|
||||
fetchMock.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await service.getPortalUrl();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheckoutUrl', () => {
|
||||
it('should return null when no API URL is configured', async () => {
|
||||
configGetMock.mockReturnValue(undefined);
|
||||
|
||||
const result = await service.getCheckoutUrl('monthly', 5);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return checkout URL on success', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ url: 'https://checkout.example.com' }),
|
||||
});
|
||||
|
||||
const result = await service.getCheckoutUrl('yearly', 10);
|
||||
|
||||
expect(result).toBe('https://checkout.example.com');
|
||||
expect(fetchMock).toHaveBeenCalledWith(`${MOCK_API_URL}/checkout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ billingInterval: 'yearly', seatCount: 10 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when API fails', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const result = await service.getCheckoutUrl('monthly', 5);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await service.getCheckoutUrl('monthly', 5);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when API response has no url', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const result = await service.getCheckoutUrl('monthly', 5);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppTokenEntity,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
ENTERPRISE_JWT_DEV_PUBLIC_KEY,
|
||||
ENTERPRISE_JWT_PUBLIC_KEY,
|
||||
} from 'src/engine/core-modules/enterprise/constants/enterprise-public-key.constant';
|
||||
import {
|
||||
type EnterpriseKeyPayload,
|
||||
type EnterpriseLicenseInfo,
|
||||
type EnterpriseValidityPayload,
|
||||
} from 'src/engine/core-modules/enterprise/types/enterprise-key-payload.type';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import {
|
||||
ConfigVariableException,
|
||||
ConfigVariableExceptionCode,
|
||||
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class EnterprisePlanService implements OnModuleInit {
|
||||
private readonly logger = new Logger(EnterprisePlanService.name);
|
||||
private cachedValidityPayload: EnterpriseValidityPayload | null = null;
|
||||
private cachedKeyPayload: EnterpriseKeyPayload | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(AppTokenEntity)
|
||||
private readonly appTokenRepository: Repository<AppTokenEntity>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.refreshKeyPayload();
|
||||
await this.loadValidityToken();
|
||||
}
|
||||
|
||||
private refreshKeyPayload(): void {
|
||||
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
|
||||
|
||||
if (!enterpriseKey) {
|
||||
this.cachedKeyPayload = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = this.verifyJwt<EnterpriseKeyPayload>(enterpriseKey);
|
||||
|
||||
this.cachedKeyPayload = payload;
|
||||
}
|
||||
|
||||
private async loadValidityToken(): Promise<void> {
|
||||
try {
|
||||
const dbToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
type: AppTokenType.EnterpriseValidityToken,
|
||||
userId: IsNull(),
|
||||
workspaceId: IsNull(),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
const tokenValue =
|
||||
dbToken?.value ??
|
||||
this.twentyConfigService.get('ENTERPRISE_VALIDITY_TOKEN');
|
||||
|
||||
if (!tokenValue) {
|
||||
this.cachedValidityPayload = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = this.verifyJwt<EnterpriseValidityPayload>(tokenValue);
|
||||
|
||||
if (payload && payload.status === 'valid') {
|
||||
this.cachedValidityPayload = payload;
|
||||
} else {
|
||||
this.cachedValidityPayload = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to load validity token: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
this.cachedValidityPayload = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveNewValidityTokenToDb(token: string): Promise<void> {
|
||||
const payload = this.verifyJwt<EnterpriseValidityPayload>(token);
|
||||
|
||||
if (!isDefined(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.appTokenRepository.manager.transaction(
|
||||
async (transactionalEntityManager) => {
|
||||
await transactionalEntityManager.update(
|
||||
this.appTokenRepository.target,
|
||||
{
|
||||
type: AppTokenType.EnterpriseValidityToken,
|
||||
userId: IsNull(),
|
||||
workspaceId: IsNull(),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
|
||||
await transactionalEntityManager.save(this.appTokenRepository.target, {
|
||||
type: AppTokenType.EnterpriseValidityToken,
|
||||
value: token,
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
hasValidSignedEnterpriseKey(): boolean {
|
||||
this.refreshKeyPayload();
|
||||
return isDefined(this.cachedKeyPayload);
|
||||
}
|
||||
|
||||
hasValidEnterpriseValidityToken(): boolean {
|
||||
if (isDefined(this.cachedValidityPayload)) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return this.cachedValidityPayload.exp > now;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
hasValidEnterpriseKey(): boolean {
|
||||
if (this.hasValidSignedEnterpriseKey()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.checkLegacyKey();
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
if (this.hasValidEnterpriseValidityToken()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.checkLegacyKey(); // temporary
|
||||
}
|
||||
|
||||
isValidEnterpriseKeyFormat(key: string): boolean {
|
||||
return this.verifyJwt<EnterpriseKeyPayload>(key) !== null;
|
||||
}
|
||||
|
||||
private checkLegacyKey(): boolean {
|
||||
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
|
||||
|
||||
if (!isDefined(enterpriseKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
'Unsigned enterprise keys are deprecated and will stop working ' +
|
||||
'in a future version. Please obtain a signed key from twenty.com.',
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async getLicenseInfo(): Promise<EnterpriseLicenseInfo> {
|
||||
this.refreshKeyPayload();
|
||||
await this.loadValidityToken();
|
||||
|
||||
if (isDefined(this.cachedValidityPayload)) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
isValid: this.cachedValidityPayload.exp > now,
|
||||
licensee: this.cachedKeyPayload?.licensee ?? null,
|
||||
expiresAt: new Date(this.cachedValidityPayload.exp * 1000),
|
||||
subscriptionId: this.cachedValidityPayload.sub,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.checkLegacyKey()) {
|
||||
return {
|
||||
isValid: true,
|
||||
licensee: null,
|
||||
expiresAt: null,
|
||||
subscriptionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
licensee: null,
|
||||
expiresAt: null,
|
||||
subscriptionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
async setEnterpriseKey(enterpriseKey: string): Promise<void> {
|
||||
try {
|
||||
await this.twentyConfigService.set('ENTERPRISE_KEY', enterpriseKey);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ConfigVariableException &&
|
||||
error.code === ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED
|
||||
) {
|
||||
throw new ConfigVariableException(
|
||||
'IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on your server. ' +
|
||||
'Please add ENTERPRISE_KEY to your .env file manually.',
|
||||
ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshValidityToken(): Promise<boolean> {
|
||||
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
|
||||
|
||||
if (!enterpriseKey) {
|
||||
this.logger.warn('No ENTERPRISE_KEY configured, skipping refresh');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.refreshKeyPayload();
|
||||
|
||||
if (!isDefined(this.cachedKeyPayload)) {
|
||||
this.logger.warn(
|
||||
'ENTERPRISE_KEY is not a valid signed JWT, skipping refresh',
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
|
||||
const validateUrl = `${apiUrl}/validate`;
|
||||
|
||||
try {
|
||||
const response = await fetch(validateUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enterpriseKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
this.logger.warn(
|
||||
`Enterprise refresh failed with status ${response.status}: ${errorData.error ?? 'Unknown error'}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.validityToken) {
|
||||
this.logger.warn('Enterprise refresh response missing validityToken');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.saveNewValidityTokenToDb(data.validityToken);
|
||||
await this.loadValidityToken();
|
||||
|
||||
this.logger.log('Enterprise validity token refreshed successfully');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Enterprise refresh failed: ${error instanceof Error ? error.message : 'Network error'}. Current validity token will continue to work until expiration.`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async reportSeats(seatCount: number): Promise<boolean> {
|
||||
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
|
||||
|
||||
if (!enterpriseKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDefined(this.cachedKeyPayload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
|
||||
const seatsUrl = `${apiUrl}/seats`;
|
||||
|
||||
try {
|
||||
const response = await fetch(seatsUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enterpriseKey, seatCount }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Seat reporting failed with status ${response.status}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log(`Reported ${seatCount} seats to enterprise API`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Seat reporting failed: ${error instanceof Error ? error.message : 'Network error'}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getSubscriptionStatus(): Promise<{
|
||||
status: string;
|
||||
licensee: string | null;
|
||||
expiresAt: Date | null;
|
||||
cancelAt: Date | null;
|
||||
currentPeriodEnd: Date | null;
|
||||
isCancellationScheduled: boolean;
|
||||
} | null> {
|
||||
this.refreshKeyPayload();
|
||||
|
||||
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
|
||||
|
||||
if (!enterpriseKey || !isDefined(this.cachedKeyPayload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const licenseInfo = await this.getLicenseInfo();
|
||||
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
|
||||
const statusUrl = `${apiUrl}/status`;
|
||||
|
||||
try {
|
||||
const response = await fetch(statusUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enterpriseKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Enterprise status request failed with status ${response.status}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
status: data.status,
|
||||
licensee: licenseInfo.licensee,
|
||||
expiresAt: licenseInfo.expiresAt,
|
||||
cancelAt: data.cancelAt ? new Date(data.cancelAt * 1000) : null,
|
||||
currentPeriodEnd: data.currentPeriodEnd
|
||||
? new Date(data.currentPeriodEnd * 1000)
|
||||
: null,
|
||||
isCancellationScheduled: data.isCancellationScheduled ?? false,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Enterprise status request failed: ${error instanceof Error ? error.message : 'Network error'}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getPortalUrl(returnUrl?: string): Promise<string | null> {
|
||||
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
|
||||
|
||||
if (!apiUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.refreshKeyPayload();
|
||||
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
|
||||
|
||||
if (enterpriseKey && isDefined(this.cachedKeyPayload)) {
|
||||
return this.requestPortalUrlWithKey(apiUrl, enterpriseKey, returnUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async requestPortalUrlWithKey(
|
||||
apiUrl: string,
|
||||
enterpriseKey: string,
|
||||
returnUrl?: string,
|
||||
): Promise<string | null> {
|
||||
const portalUrl = `${apiUrl}/portal`;
|
||||
|
||||
try {
|
||||
const response = await fetch(portalUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enterpriseKey, returnUrl }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Enterprise portal request failed with status ${response.status}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data.url ?? null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Enterprise portal request failed: ${error instanceof Error ? error.message : 'Network error'}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getCheckoutUrl(
|
||||
billingInterval: 'monthly' | 'yearly' = 'monthly',
|
||||
seatCount: number,
|
||||
): Promise<string | null> {
|
||||
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
|
||||
|
||||
if (!apiUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkoutUrl = `${apiUrl}/checkout`;
|
||||
|
||||
try {
|
||||
const response = await fetch(checkoutUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ billingInterval, seatCount }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Enterprise checkout request failed with status ${response.status}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data.url ?? null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Enterprise checkout request failed: ${error instanceof Error ? error.message : 'Network error'}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getPublicKey(): string {
|
||||
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
|
||||
|
||||
return nodeEnv === NodeEnvironment.DEVELOPMENT
|
||||
? ENTERPRISE_JWT_DEV_PUBLIC_KEY
|
||||
: ENTERPRISE_JWT_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
private verifyJwt<T extends Record<string, unknown>>(
|
||||
token: string,
|
||||
): T | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signatureBuffer = Buffer.from(
|
||||
signature.replace(/-/g, '+').replace(/_/g, '/') +
|
||||
'='.repeat((4 - (signature.length % 4)) % 4),
|
||||
'base64',
|
||||
);
|
||||
|
||||
const isValid = crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(signingInput),
|
||||
{
|
||||
key: this.getPublicKey(),
|
||||
padding: crypto.constants.RSA_PKCS1_PADDING,
|
||||
},
|
||||
signatureBuffer,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadStr = Buffer.from(
|
||||
encodedPayload.replace(/-/g, '+').replace(/_/g, '/') +
|
||||
'='.repeat((4 - (encodedPayload.length % 4)) % 4),
|
||||
'base64',
|
||||
).toString('utf-8');
|
||||
|
||||
return JSON.parse(payloadStr) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export type EnterpriseKeyPayload = {
|
||||
sub: string;
|
||||
licensee: string;
|
||||
iat: number;
|
||||
};
|
||||
|
||||
export type EnterpriseValidityPayload = {
|
||||
sub: string;
|
||||
status: 'valid';
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type EnterpriseLicenseInfo = {
|
||||
isValid: boolean;
|
||||
licensee: string | null;
|
||||
expiresAt: Date | null;
|
||||
subscriptionId: string | null;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
@@ -17,6 +18,7 @@ import { EventLogsService } from './event-logs.service';
|
||||
ClickHouseModule,
|
||||
PermissionsModule,
|
||||
BillingModule,
|
||||
EnterpriseModule,
|
||||
GuardRedirectModule,
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity]),
|
||||
],
|
||||
|
||||
@@ -11,18 +11,20 @@ import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/j
|
||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { UpdateWorkspaceMemberEmailJob } from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job';
|
||||
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { WebhookJobModule } from 'src/engine/metadata-modules/webhook/jobs/webhook-job.module';
|
||||
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
|
||||
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { WebhookJobModule } from 'src/engine/metadata-modules/webhook/jobs/webhook-job.module';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
import { CleanOnboardingWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job';
|
||||
import { CleanSuspendedWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job';
|
||||
@@ -32,7 +34,6 @@ import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/cale
|
||||
import { CalendarModule } from 'src/modules/calendar/calendar.module';
|
||||
import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module';
|
||||
import { FavoriteModule } from 'src/modules/favorite/favorite.module';
|
||||
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
|
||||
import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module';
|
||||
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
|
||||
@@ -67,6 +68,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
AuditJobModule,
|
||||
AiAgentMonitorModule,
|
||||
LogicFunctionModule,
|
||||
EnterpriseModule,
|
||||
],
|
||||
providers: [
|
||||
CleanSuspendedWorkspacesJob,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
|
||||
@@ -23,6 +24,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
FeatureFlagEntity,
|
||||
]),
|
||||
BillingModule,
|
||||
EnterpriseModule,
|
||||
GuardRedirectModule,
|
||||
PermissionsModule,
|
||||
FeatureFlagModule,
|
||||
|
||||
@@ -1536,6 +1536,24 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
ENTERPRISE_KEY: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
isSensitive: true,
|
||||
description:
|
||||
'Signed enterprise validity token (JWT). Used as fallback when no token is stored in the database.',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsOptional()
|
||||
ENTERPRISE_VALIDITY_TOKEN: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
description: 'Base URL for the Enterprise API on twenty.com',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsOptional()
|
||||
ENTERPRISE_API_URL: string = 'https://twenty.com/api/enterprise';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.OTHER,
|
||||
description: 'Health monitoring time window in minutes',
|
||||
|
||||
@@ -201,6 +201,10 @@ export class TwentyConfigService {
|
||||
return this.get('TYPEORM_LOGGING');
|
||||
}
|
||||
|
||||
isBillingEnabled(): boolean {
|
||||
return this.get('IS_BILLING_ENABLED') === true;
|
||||
}
|
||||
|
||||
private validateNotEnvOnly<T extends keyof ConfigVariables>(
|
||||
key: T,
|
||||
operation: string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
@@ -49,6 +50,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
|
||||
TokenModule,
|
||||
PermissionsModule,
|
||||
OnboardingModule,
|
||||
EnterpriseModule,
|
||||
FeatureFlagModule,
|
||||
],
|
||||
services: [UserWorkspaceService],
|
||||
|
||||
+8
@@ -572,4 +572,12 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
public async getActiveUserWorkspaceCountTotal(): Promise<number> {
|
||||
const count = await this.userWorkspaceRepository.count({
|
||||
where: { deletedAt: IsNull() },
|
||||
});
|
||||
|
||||
return Math.max(1, count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manage
|
||||
import { CustomDomainManagerModule } from 'src/engine/core-modules/domain/custom-domain-manager/custom-domain-manager.module';
|
||||
import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
@@ -29,6 +30,7 @@ import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspa
|
||||
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
|
||||
import { BillingDisabledGuard } from 'src/engine/guards/billing-disabled.guard';
|
||||
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
@@ -76,6 +78,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
|
||||
ViewModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
ApplicationModule,
|
||||
EnterpriseModule,
|
||||
],
|
||||
services: [WorkspaceService],
|
||||
resolvers: workspaceAutoResolverOpts,
|
||||
@@ -86,6 +89,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
|
||||
WorkspaceResolver,
|
||||
WorkspaceService,
|
||||
WorkspaceGaugeService,
|
||||
BillingDisabledGuard,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
],
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/dom
|
||||
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
|
||||
import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
@@ -97,6 +98,7 @@ export class WorkspaceResolver {
|
||||
private readonly dnsManagerService: DnsManagerService,
|
||||
private readonly customDomainManagerService: CustomDomainManagerService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
) {}
|
||||
|
||||
@Query(() => WorkspaceEntity)
|
||||
@@ -174,7 +176,7 @@ export class WorkspaceResolver {
|
||||
async billingSubscriptions(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
): Promise<BillingSubscriptionEntity[] | undefined> {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
if (!this.twentyConfigService.isBillingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -272,7 +274,7 @@ export class WorkspaceResolver {
|
||||
async currentBillingSubscription(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
): Promise<BillingSubscriptionEntity | undefined> {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
if (!this.twentyConfigService.isBillingEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -310,7 +312,17 @@ export class WorkspaceResolver {
|
||||
|
||||
@ResolveField(() => Boolean)
|
||||
hasValidEnterpriseKey(): boolean {
|
||||
return isDefined(this.twentyConfigService.get('ENTERPRISE_KEY'));
|
||||
return this.enterprisePlanService.hasValidEnterpriseKey();
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean)
|
||||
hasValidSignedEnterpriseKey(): boolean {
|
||||
return this.enterprisePlanService.hasValidSignedEnterpriseKey();
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean)
|
||||
hasValidEnterpriseValidityToken(): boolean {
|
||||
return this.enterprisePlanService.hasValidEnterpriseValidityToken();
|
||||
}
|
||||
|
||||
@ResolveField(() => WorkspaceUrlsDTO)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingDisabledGuard implements CanActivate {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
canActivate(_context: ExecutionContext): boolean {
|
||||
return !this.twentyConfigService.isBillingEnabled();
|
||||
}
|
||||
}
|
||||
+2
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
|
||||
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
|
||||
@@ -24,6 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceMigrationModule,
|
||||
BillingModule,
|
||||
ApplicationModule,
|
||||
EnterpriseModule,
|
||||
],
|
||||
providers: [
|
||||
RowLevelPermissionPredicateService,
|
||||
|
||||
+4
-6
@@ -8,7 +8,7 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { fromFlatRowLevelPermissionPredicateGroupToDto } from 'src/engine/metadata-modules/flat-row-level-permission-predicate/utils/from-flat-row-level-permission-predicate-group-to-dto.util';
|
||||
@@ -24,7 +24,7 @@ export class RowLevelPermissionPredicateGroupService {
|
||||
private readonly billingService: BillingService,
|
||||
@InjectRepository(RowLevelPermissionPredicateGroupEntity)
|
||||
private readonly rowLevelPermissionPredicateGroupRepository: Repository<RowLevelPermissionPredicateGroupEntity>,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
) {}
|
||||
|
||||
async findByWorkspaceId(
|
||||
@@ -136,9 +136,7 @@ export class RowLevelPermissionPredicateGroupService {
|
||||
private async hasRowLevelPermissionFeature(
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const hasValidEnterpriseKey = isDefined(
|
||||
this.twentyConfigService.get('ENTERPRISE_KEY'),
|
||||
);
|
||||
const hasValidEnterprisePlan = this.enterprisePlanService.isValid();
|
||||
|
||||
const isRowLevelPermissionEnabled =
|
||||
await this.billingService.hasEntitlement(
|
||||
@@ -146,6 +144,6 @@ export class RowLevelPermissionPredicateGroupService {
|
||||
BillingEntitlementKey.RLS,
|
||||
);
|
||||
|
||||
return hasValidEnterpriseKey && isRowLevelPermissionEnabled;
|
||||
return hasValidEnterprisePlan && isRowLevelPermissionEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -9,7 +9,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
@@ -46,8 +46,8 @@ export class RowLevelPermissionPredicateService {
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
) {}
|
||||
|
||||
async findByWorkspaceId(
|
||||
@@ -555,9 +555,7 @@ export class RowLevelPermissionPredicateService {
|
||||
private async hasRowLevelPermissionFeature(
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const hasValidEnterpriseKey = isDefined(
|
||||
this.twentyConfigService.get('ENTERPRISE_KEY'),
|
||||
);
|
||||
const hasValidEnterprisePlan = this.enterprisePlanService.isValid();
|
||||
|
||||
const isRowLevelPermissionEnabled =
|
||||
await this.billingService.hasEntitlement(
|
||||
@@ -565,7 +563,7 @@ export class RowLevelPermissionPredicateService {
|
||||
BillingEntitlementKey.RLS,
|
||||
);
|
||||
|
||||
return hasValidEnterpriseKey && isRowLevelPermissionEnabled;
|
||||
return hasValidEnterprisePlan && isRowLevelPermissionEnabled;
|
||||
}
|
||||
|
||||
private async hasRowLevelPermissionFeatureOrThrow(workspaceId: string) {
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ import {
|
||||
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
||||
import { seedAgents } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util';
|
||||
import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util';
|
||||
|
||||
import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util';
|
||||
import { seedServerId } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-server-id.util';
|
||||
import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum SettingsPath {
|
||||
NewImapSmtpCaldavConnection = 'accounts/new-imap-smtp-caldav-connection',
|
||||
EditImapSmtpCaldavConnection = 'accounts/edit-imap-smtp-caldav-connection/:connectedAccountId',
|
||||
Billing = 'billing',
|
||||
Enterprise = 'enterprise',
|
||||
Objects = 'objects',
|
||||
ObjectOverview = 'objects/overview',
|
||||
ObjectDetail = 'objects/:objectNamePlural',
|
||||
@@ -55,6 +56,7 @@ export enum SettingsPath {
|
||||
EventLogs = 'security/event-logs',
|
||||
|
||||
AdminPanel = 'admin-panel',
|
||||
AdminPanelEnterprise = 'admin-panel#enterprise',
|
||||
AdminPanelHealthStatus = 'admin-panel#health-status',
|
||||
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorId',
|
||||
AdminPanelQueueDetail = 'admin-panel/health-status/queue/:queueName',
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"next-runtime-env": "^3.3.0",
|
||||
"postgres": "^3.4.3",
|
||||
"react-tooltip": "^5.13.1",
|
||||
"stripe": "^20.3.1",
|
||||
"twenty-ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ContentContainer } from '@/app/_components/ui/layout/ContentContainer';
|
||||
|
||||
type ActivationResult = {
|
||||
enterpriseKey: string;
|
||||
licensee: string;
|
||||
subscriptionId: string;
|
||||
};
|
||||
|
||||
export default function EnterpriseActivatePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get('session_id');
|
||||
const [result, setResult] = useState<ActivationResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('No session ID provided. Please complete the checkout first.');
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const activate = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/enterprise/activate?session_id=${sessionId}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Activation failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setResult(data);
|
||||
} catch {
|
||||
setError('Failed to activate enterprise key. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
activate();
|
||||
}, [sessionId]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!result) return;
|
||||
|
||||
await navigator.clipboard.writeText(result.enterpriseKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<div style={{ minHeight: '60vh', marginTop: '50px', maxWidth: '700px' }}>
|
||||
<h1 style={{ fontSize: '2rem', marginBottom: '1rem' }}>
|
||||
Enterprise Activation
|
||||
</h1>
|
||||
|
||||
{loading && <p>Activating your enterprise license...</p>}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
color: '#dc2626',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div>
|
||||
<p style={{ marginBottom: '1rem', color: '#16a34a' }}>
|
||||
Your enterprise license has been activated successfully.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<strong>Licensee:</strong> {result.licensee}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Your Enterprise Key:</strong>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
color: '#6b7280',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
Copy this key and paste it into your Twenty self-hosted instance
|
||||
settings.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: '#f3f4f6',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{result.enterpriseKey}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
right: '0.5rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: copied ? '#16a34a' : '#111827',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '2rem',
|
||||
padding: '1rem',
|
||||
background: '#eff6ff',
|
||||
border: '1px solid #bfdbfe',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<strong>Next steps:</strong>
|
||||
<ol
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
paddingLeft: '1.25rem',
|
||||
lineHeight: '1.75',
|
||||
}}
|
||||
>
|
||||
<li>Copy the enterprise key above</li>
|
||||
<li>
|
||||
Open your Twenty self-hosted instance Settings →
|
||||
Enterprise
|
||||
</li>
|
||||
<li>Paste the key and click Activate</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContentContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { signEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const sessionId = url.searchParams.get('session_id');
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing session_id parameter' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ['subscription', 'customer'],
|
||||
});
|
||||
|
||||
if (session.payment_status !== 'paid') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Payment not completed' }),
|
||||
{ status: 402, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = session.subscription;
|
||||
|
||||
if (!subscription || typeof subscription === 'string') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Subscription not found' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const customer = session.customer;
|
||||
const licensee =
|
||||
customer && typeof customer !== 'string' && !customer.deleted
|
||||
? (customer.name ?? customer.email ?? 'Unknown')
|
||||
: 'Unknown';
|
||||
|
||||
const enterpriseKey = signEnterpriseKey(subscription.id, licensee);
|
||||
|
||||
return Response.json({
|
||||
enterpriseKey,
|
||||
licensee,
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Activation error: ${message}` }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
getEnterprisePriceId,
|
||||
getStripeClient,
|
||||
} from '@/shared/enterprise/stripe-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const body = await request.json();
|
||||
const billingInterval = body.billingInterval === 'yearly' ? 'yearly' : 'monthly';
|
||||
const priceId = getEnterprisePriceId(billingInterval);
|
||||
const successUrl =
|
||||
body.successUrl ??
|
||||
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/enterprise/activate?session_id={CHECKOUT_SESSION_ID}`;
|
||||
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: body.seatCount ?? 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
subscription_data: {
|
||||
trial_period_days: 30,
|
||||
metadata: {
|
||||
source: 'enterprise-self-hosted',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ url: session.url });
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Checkout error: ${message}` }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import {
|
||||
getStripeClient
|
||||
} from '@/shared/enterprise/stripe-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey, returnUrl } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing enterpriseKey' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid enterprise key' }),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const frontendUrl = process.env.NEXT_PUBLIC_WEBSITE_URL;
|
||||
const fullReturnUrl = returnUrl
|
||||
? `${frontendUrl}${returnUrl}`
|
||||
: frontendUrl;
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: fullReturnUrl,
|
||||
});
|
||||
|
||||
return Response.json({ url: session.url });
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Portal error: ${message}` }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey, seatCount } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing enterpriseKey' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof seatCount !== 'number' || seatCount < 1) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid seatCount' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid enterprise key' }),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const NON_UPDATABLE_STATUSES = [
|
||||
'canceled',
|
||||
'incomplete_expired',
|
||||
];
|
||||
|
||||
if (
|
||||
NON_UPDATABLE_STATUSES.includes(subscription.status) ||
|
||||
subscription.cancel_at_period_end
|
||||
) {
|
||||
return Response.json({
|
||||
success: false,
|
||||
reason: 'Subscription is canceled or scheduled for cancellation',
|
||||
seatCount: subscription.items.data[0]?.quantity ?? 0,
|
||||
subscriptionId: payload.sub,
|
||||
});
|
||||
}
|
||||
|
||||
if (!subscription.items.data[0]) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No subscription item found' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionItemId = subscription.items.data[0].id;
|
||||
|
||||
await stripe.subscriptions.update(payload.sub, {
|
||||
items: [
|
||||
{
|
||||
id: subscriptionItemId,
|
||||
quantity: seatCount,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
seatCount,
|
||||
subscriptionId: payload.sub,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Seat update error: ${message}` }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { verifyEnterpriseKey } from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing enterpriseKey' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid enterprise key' }),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const rawCancelAt = subscription.cancel_at;
|
||||
const rawCancelAtPeriodEnd = subscription.cancel_at_period_end;
|
||||
const rawCurrentPeriodEnd = (subscription as any).current_period_end as
|
||||
| number
|
||||
| null;
|
||||
|
||||
const effectiveCancelAt =
|
||||
rawCancelAt ?? (rawCancelAtPeriodEnd ? rawCurrentPeriodEnd : null);
|
||||
|
||||
const isCancellationScheduled =
|
||||
subscription.status !== 'canceled' && effectiveCancelAt !== null;
|
||||
|
||||
return Response.json({
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
cancelAt: effectiveCancelAt,
|
||||
currentPeriodEnd: rawCurrentPeriodEnd,
|
||||
isCancellationScheduled,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Status error: ${message}` }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
signValidityToken,
|
||||
verifyEnterpriseKey,
|
||||
} from '@/shared/enterprise/enterprise-jwt';
|
||||
import { getStripeClient } from '@/shared/enterprise/stripe-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enterpriseKey } = body;
|
||||
|
||||
if (!enterpriseKey || typeof enterpriseKey !== 'string') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing enterpriseKey' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = verifyEnterpriseKey(enterpriseKey);
|
||||
|
||||
if (!payload) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid enterprise key' }),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(payload.sub);
|
||||
|
||||
const activeStatuses = ['active', 'trialing'];
|
||||
|
||||
if (!activeStatuses.includes(subscription.status)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Subscription is not active',
|
||||
status: subscription.status,
|
||||
}),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
const rawCancelAt = subscription.cancel_at;
|
||||
const rawCancelAtPeriodEnd = subscription.cancel_at_period_end;
|
||||
const rawCurrentPeriodEnd = (subscription as { current_period_end?: number })
|
||||
.current_period_end;
|
||||
const effectiveCancelAt =
|
||||
rawCancelAt ??
|
||||
(rawCancelAtPeriodEnd && rawCurrentPeriodEnd ? rawCurrentPeriodEnd : null);
|
||||
|
||||
const validityToken = signValidityToken(payload.sub, {
|
||||
subscriptionCancelAt: effectiveCancelAt,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
validityToken,
|
||||
licensee: payload.licensee,
|
||||
subscriptionId: payload.sub,
|
||||
subscriptionStatus: subscription.status,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Validation error: ${message}` }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export type EnterpriseKeyPayload = {
|
||||
sub: string;
|
||||
licensee: string;
|
||||
iat: number;
|
||||
};
|
||||
|
||||
export type EnterpriseValidityPayload = {
|
||||
sub: string;
|
||||
status: 'valid';
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
const ALGORITHM = 'RS256';
|
||||
const DEFAULT_VALIDITY_TOKEN_DURATION_DAYS= 30;
|
||||
|
||||
const getValidityTokenDurationDays = (): number => {
|
||||
const value = process.env.ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS;
|
||||
|
||||
if (value === undefined || value === '') {
|
||||
return DEFAULT_VALIDITY_TOKEN_DURATION_DAYS;
|
||||
}
|
||||
|
||||
const parsed = parseInt(value, 10);
|
||||
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
return DEFAULT_VALIDITY_TOKEN_DURATION_DAYS;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export type SignValidityTokenOptions = {
|
||||
subscriptionCancelAt: number | null;
|
||||
};
|
||||
|
||||
const computeValidityExp = (
|
||||
nowSeconds: number,
|
||||
durationDays: number,
|
||||
subscriptionCancelAt: number | null,
|
||||
): number => {
|
||||
const defaultExp = nowSeconds + durationDays * 24 * 60 * 60;
|
||||
|
||||
if (subscriptionCancelAt === null || subscriptionCancelAt <= 0) {
|
||||
return defaultExp;
|
||||
}
|
||||
|
||||
return Math.min(defaultExp, subscriptionCancelAt);
|
||||
};
|
||||
|
||||
const getPrivateKey = (): string => {
|
||||
const key = process.env.ENTERPRISE_JWT_PRIVATE_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('ENTERPRISE_JWT_PRIVATE_KEY is not configured');
|
||||
}
|
||||
|
||||
return key.replace(/\\n/g, '\n');
|
||||
};
|
||||
|
||||
const base64UrlEncode = (data: string): string => {
|
||||
return Buffer.from(data)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
};
|
||||
|
||||
const base64UrlDecode = (data: string): string => {
|
||||
const padded = data + '='.repeat((4 - (data.length % 4)) % 4);
|
||||
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
return Buffer.from(base64, 'base64').toString('utf-8');
|
||||
};
|
||||
|
||||
const signJwt = (
|
||||
payload: Record<string, unknown>,
|
||||
privateKey: string,
|
||||
): string => {
|
||||
const header = { alg: ALGORITHM, typ: 'JWT' };
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signature = crypto
|
||||
.sign('sha256', Buffer.from(signingInput), {
|
||||
key: privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PADDING,
|
||||
})
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
};
|
||||
|
||||
export const verifyJwt = <T extends Record<string, unknown>>(
|
||||
token: string,
|
||||
publicKey: string,
|
||||
): T | null => {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signatureBuffer = Buffer.from(
|
||||
signature.replace(/-/g, '+').replace(/_/g, '/') +
|
||||
'='.repeat((4 - (signature.length % 4)) % 4),
|
||||
'base64',
|
||||
);
|
||||
|
||||
const isValid = crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(signingInput),
|
||||
{
|
||||
key: publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PADDING,
|
||||
},
|
||||
signatureBuffer,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(base64UrlDecode(encodedPayload)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const signEnterpriseKey = (
|
||||
subscriptionId: string,
|
||||
licensee: string,
|
||||
): string => {
|
||||
const payload: EnterpriseKeyPayload = {
|
||||
sub: subscriptionId,
|
||||
licensee,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
return signJwt(payload, getPrivateKey());
|
||||
};
|
||||
|
||||
export const signValidityToken = (
|
||||
subscriptionId: string,
|
||||
options?: SignValidityTokenOptions,
|
||||
): string => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const durationDays = getValidityTokenDurationDays();
|
||||
const subscriptionCancelAt = options?.subscriptionCancelAt ?? null;
|
||||
const exp = computeValidityExp(now, durationDays, subscriptionCancelAt);
|
||||
|
||||
const payload: EnterpriseValidityPayload = {
|
||||
sub: subscriptionId,
|
||||
status: 'valid',
|
||||
iat: now,
|
||||
exp,
|
||||
};
|
||||
|
||||
return signJwt(payload, getPrivateKey());
|
||||
};
|
||||
|
||||
export const verifyEnterpriseKey = (
|
||||
token: string,
|
||||
): EnterpriseKeyPayload | null => {
|
||||
const publicKey = getPublicKey();
|
||||
|
||||
return verifyJwt<EnterpriseKeyPayload>(token, publicKey);
|
||||
};
|
||||
|
||||
const getPublicKey = (): string => {
|
||||
const key = process.env.ENTERPRISE_JWT_PUBLIC_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('ENTERPRISE_JWT_PUBLIC_KEY is not configured');
|
||||
}
|
||||
|
||||
return key.replace(/\\n/g, '\n');
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
let stripeInstance: Stripe | null = null;
|
||||
|
||||
export const getStripeClient = (): Stripe => {
|
||||
if (!stripeInstance) {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (!secretKey) {
|
||||
throw new Error('STRIPE_SECRET_KEY is not configured');
|
||||
}
|
||||
|
||||
stripeInstance = new Stripe(secretKey, {});
|
||||
}
|
||||
|
||||
return stripeInstance;
|
||||
};
|
||||
|
||||
export const getEnterprisePriceId = (
|
||||
billingInterval: 'monthly' | 'yearly' = 'monthly',
|
||||
): string => {
|
||||
const envKey =
|
||||
billingInterval === 'yearly'
|
||||
? 'STRIPE_ENTERPRISE_YEARLY_PRICE_ID'
|
||||
: 'STRIPE_ENTERPRISE_MONTHLY_PRICE_ID';
|
||||
|
||||
const priceId = process.env[envKey];
|
||||
|
||||
if (!priceId) {
|
||||
throw new Error(`${envKey} is not configured`);
|
||||
}
|
||||
|
||||
return priceId;
|
||||
};
|
||||
Reference in New Issue
Block a user