(Billing for self hosts) Tie enterprise key to server (#22464)
# Enterprise key: bind to a server, free dev instances, self-serve transfer, shorter license ## Summary Enterprise keys were being reused across multiple instances (e.g. one prod + one dev, or several environments), which broke seat accounting and made licensing ambiguous. This PR ties each enterprise key to a **single server**, while giving customers a legitimate, self-serve way to run a **free development instance** and to **move their key** when they replace a server. ## Product behavior ### 1. Enterprise key is bound to one server - The first server to validate an enterprise key **claims** it (claim-on-first-use). From then on, that key is bound to that one server (until unbound - see 3.). - Any other instance that presents the **same key from a different server is hard-rejected**: it does not receive a license, so enterprise features stay off there. - Each instance has a stable server identifier. If one isn't set, the instance generates and persists one automatically on first validation (in keyValuePair table), so existing customers generally don't need to do anything (unless they have disabled config variables in db then they should add it to .env). ### 2. Free development instance - Every enterprise subscription gets **one free, non-billable development instance** in addition to its production instance. - An instance registers as development by declaring its instance type as `development` (done by default when validating the enterprise key, then can be toggled from UI or by updating value in keyValuePair table). - The free dev slot is only granted while there is an **active production instance** on the same subscription (so it's a perk for paying customers, not a way to run for free). - Only **one** dev instance can be active at a time per subscription, and it is **not counted as a billable seat**. ### 3. Self-serve unbind / rebind (transfer) - Admins can **release** the binding from the enterprise settings, which frees the key so it can be **claimed by a new server**. - This is the intended path when **sunsetting an instance and standing up a new one** (migration, re-hosting, disaster recovery): release on the old/dead box, then the new box claims it on its next validation. - To prevent abuse, releases are **rate-limited (10 per rolling 30 days)**; hitting the limit shows a clear message. ### 4. Automatic release of dead servers - If a bound server stops checking in for **14 days**, its binding is considered stale and is **auto-released**, so a replacement can claim the key without any manual step. This covers the case where the old server is already gone and can't release itself. ### 5. Shorter license validity (30 → 7 days) - The license (validity token) now expires after **7 days** instead of 30. The daily background refresh keeps healthy instances licensed transparently. - This limits the value of copying a license from one instance to another, since a copied license now stops working within a week. ### 6. License issuance is rate-limited - Issuing a new license is capped at **twice per 24h, independently for production and for development**. This tolerates the normal daily refresh (including small drift between runs) while blocking bursts of license minting for cloned instances. - Hitting this limit never revokes an existing, still-valid license — the current one keeps working until it expires; the manual "refresh" button just reports that the daily limit was reached. ## What changes for existing self-hosted customers **If you run a single production instance with one enterprise key:** nothing to do. On the next validation your instance reports its server identifier, claims the binding, and keeps working. **If you reuse one key across several instances (e.g. prod + dev, or multiple environments):** only the **first** instance to validate keeps its license. The others will **lose enterprise features**. To migrate: - Keep your production instance as-is (it claims the binding). - For a secondary/testing box, mark it as a **development instance** (set the instance type to `development`) to use the free dev slot — no extra cost. - If you genuinely need multiple production instances, you'll need **separate subscriptions/keys** for each. **If you're replacing a server (decommissioning + rebuilding):** - **Release** the binding from enterprise settings on the old instance, then start the new one — it will claim the key automatically. - If the old server is already gone, just wait for the **14-day auto-release**, or contact support. **Legacy instances that can't persist a server identifier automatically:** set the server identifier explicitly in your environment configuration (the instance logs a message telling you to do so). **Offline instances:** because licenses now last 7 days, an instance that can't reach our licensing endpoint for more than a week will lose enterprise features until it can check in again. > A migration email will be sent to affected customers separately. ## Technical implementation (brief) - Binding state lives in the **subscription's billing metadata** (bound server id + last-seen timestamps for prod and dev, release timestamps, and license-issuance timestamps). No new database is introduced on the licensing side; the billing provider's subscription metadata is the source of truth. <img width="976" height="413" alt="metadata_3" src="https://github.com/user-attachments/assets/ccc64822-e177-4223-a65a-4a4602aedf0e" /> - On each validation, a pure **binding resolver** takes the reported server id + instance type + current metadata and returns `allowed` (with the metadata to persist and whether the seat is billable) or `rejected`. It handles claim-on-first-use, staleness/auto-release, the dev-requires-active-prod rule, and the single-dev-slot rule. - **Rate limits** (release + license issuance) use a shared sliding-window helper stored as pruned timestamp lists in the same metadata, so the metadata self-cleans and never grows unbounded. License issuance uses **separate windows per instance type**. - The self-hosted instance **generates and persists a server identifier** if none is configured, and sends it (plus instance type) as instance metadata on validation. - A rejected binding returns a specific error code; the instance **revokes its stored license** on that code. A license-issuance rate-limit instead **throws a typed exception that surfaces to the manual refresh** while leaving the existing license untouched; the daily refresh job swallows it. - License lifetime is a configurable duration (defaulted from 30 to **7 days**), clamped to the subscription's cancellation date when sooner.
This commit is contained in:
@@ -924,6 +924,7 @@ export type ClientConfig = {
|
||||
canManageFeatureFlags: Scalars['Boolean']['output'];
|
||||
captcha: Captcha;
|
||||
defaultSubdomain?: Maybe<Scalars['String']['output']>;
|
||||
enterpriseInstanceType: Scalars['String']['output'];
|
||||
frontDomain: Scalars['String']['output'];
|
||||
isAttachmentPreviewEnabled: Scalars['Boolean']['output'];
|
||||
isClickHouseConfigured: Scalars['Boolean']['output'];
|
||||
@@ -2641,6 +2642,7 @@ export type Mutation = {
|
||||
/** @deprecated Use installApplication instead */
|
||||
installMarketplaceApp: Scalars['Boolean']['output'];
|
||||
refreshEnterpriseValidityToken: Scalars['Boolean']['output'];
|
||||
releaseEnterpriseServerBinding: EnterpriseLicenseInfoDto;
|
||||
removeQueryFromEventStream: Scalars['Boolean']['output'];
|
||||
removeRoleFromAgent: Scalars['Boolean']['output'];
|
||||
renameChatThread: AgentChatThread;
|
||||
@@ -8080,6 +8082,11 @@ export type RefreshEnterpriseValidityTokenMutationVariables = Exact<{ [key: stri
|
||||
|
||||
export type RefreshEnterpriseValidityTokenMutation = { __typename?: 'Mutation', refreshEnterpriseValidityToken: boolean };
|
||||
|
||||
export type ReleaseEnterpriseServerBindingMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ReleaseEnterpriseServerBindingMutation = { __typename?: 'Mutation', releaseEnterpriseServerBinding: { __typename?: 'EnterpriseLicenseInfoDTO', isValid: boolean, licensee?: string | null, expiresAt?: string | null, subscriptionId?: string | null } };
|
||||
|
||||
export type SetEnterpriseKeyMutationVariables = Exact<{
|
||||
enterpriseKey: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -9016,6 +9023,7 @@ export const DeleteEmailingDomainDocument = {"kind":"Document","definitions":[{"
|
||||
export const VerifyEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>;
|
||||
export const GetEmailingDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verificationRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>;
|
||||
export const RefreshEnterpriseValidityTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RefreshEnterpriseValidityToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"refreshEnterpriseValidityToken"}}]}}]} as unknown as DocumentNode<RefreshEnterpriseValidityTokenMutation, RefreshEnterpriseValidityTokenMutationVariables>;
|
||||
export const ReleaseEnterpriseServerBindingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReleaseEnterpriseServerBinding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"releaseEnterpriseServerBinding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}}]}}]}}]} as unknown as DocumentNode<ReleaseEnterpriseServerBindingMutation, ReleaseEnterpriseServerBindingMutationVariables>;
|
||||
export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetEnterpriseKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enterpriseKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setEnterpriseKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"enterpriseKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enterpriseKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}}]}}]}}]} as unknown as DocumentNode<SetEnterpriseKeyMutation, SetEnterpriseKeyMutationVariables>;
|
||||
export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseCheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"billingInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}}}]}]}}]} as unknown as DocumentNode<EnterpriseCheckoutSessionQuery, EnterpriseCheckoutSessionQueryVariables>;
|
||||
export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode<EnterprisePortalSessionQuery, EnterprisePortalSessionQueryVariables>;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
|
||||
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
|
||||
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { enterpriseInstanceTypeState } from '@/client-config/states/enterpriseInstanceTypeState';
|
||||
import { isEmailingDomainInDemoModeState } from '@/client-config/states/isEmailingDomainInDemoModeState';
|
||||
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
|
||||
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
|
||||
@@ -34,6 +35,7 @@ import { getClientConfig } from '@/client-config/utils/getClientConfig';
|
||||
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants';
|
||||
|
||||
type UseClientConfigResult = {
|
||||
data: { clientConfig: ClientConfig } | undefined;
|
||||
@@ -126,6 +128,10 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
|
||||
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
|
||||
|
||||
const setEnterpriseInstanceType = useSetAtomState(
|
||||
enterpriseInstanceTypeState,
|
||||
);
|
||||
|
||||
const setAppVersion = useSetAtomState(appVersionState);
|
||||
|
||||
const fetchClientConfig = useCallback(async () => {
|
||||
@@ -210,6 +216,10 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false);
|
||||
setIsDDLLocked(clientConfig?.isWorkspaceSchemaDDLLocked ?? false);
|
||||
setMaintenanceMode(clientConfig?.maintenance ?? null);
|
||||
setEnterpriseInstanceType(
|
||||
clientConfig?.enterpriseInstanceType ??
|
||||
ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
|
||||
);
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error('Failed to fetch client config');
|
||||
@@ -248,6 +258,7 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsDDLLocked,
|
||||
setLabPublicFeatureFlags,
|
||||
setMaintenanceMode,
|
||||
setEnterpriseInstanceType,
|
||||
setIsMicrosoftCalendarEnabled,
|
||||
setIsMicrosoftMessagingEnabled,
|
||||
setSentryConfig,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import {
|
||||
ENTERPRISE_INSTANCE_TYPE,
|
||||
type EnterpriseInstanceType,
|
||||
} from 'twenty-shared/constants';
|
||||
|
||||
export const enterpriseInstanceTypeState =
|
||||
createAtomState<EnterpriseInstanceType>({
|
||||
key: 'enterpriseInstanceTypeState',
|
||||
defaultValue: ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type Sentry,
|
||||
type Support,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { type EnterpriseInstanceType } from 'twenty-shared/constants';
|
||||
import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig';
|
||||
|
||||
export type ClientConfig = {
|
||||
@@ -45,4 +46,5 @@ export type ClientConfig = {
|
||||
isTwoFactorAuthenticationEnabled: boolean;
|
||||
allowRequestsToTwentyIcons: boolean;
|
||||
maintenance?: ClientConfigMaintenanceMode;
|
||||
enterpriseInstanceType?: EnterpriseInstanceType;
|
||||
};
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { InformationBannerBillingSubscriptionPaused } from '@/information-banner/components/billing/InformationBannerBillingSubscriptionPaused';
|
||||
import { InformationBannerNonProductionInstance } from '@/information-banner/components/enterprise/InformationBannerNonProductionInstance';
|
||||
import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod';
|
||||
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
|
||||
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
|
||||
@@ -63,6 +64,7 @@ export const InformationBannerWrapper = () => {
|
||||
|
||||
return (
|
||||
<StyledInformationBannerWrapper>
|
||||
<InformationBannerNonProductionInstance />
|
||||
<InformationBannerMaintenance />
|
||||
{isAccountSyncEnabled && (
|
||||
<InformationBannerReconnectAccountInsufficientPermissions />
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { enterpriseInstanceTypeState } from '@/client-config/states/enterpriseInstanceTypeState';
|
||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const InformationBannerNonProductionInstance = () => {
|
||||
const enterpriseInstanceType = useAtomStateValue(enterpriseInstanceTypeState);
|
||||
|
||||
if (
|
||||
!isDefined(enterpriseInstanceType) ||
|
||||
enterpriseInstanceType === ENTERPRISE_INSTANCE_TYPE.PRODUCTION
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InformationBanner
|
||||
componentInstanceId="information-banner-non-production-instance"
|
||||
variant="secondary"
|
||||
message={t`This is a non-production instance.`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const RELEASE_ENTERPRISE_SERVER_BINDING = gql`
|
||||
mutation ReleaseEnterpriseServerBinding {
|
||||
releaseEnterpriseServerBinding {
|
||||
isValid
|
||||
licensee
|
||||
expiresAt
|
||||
subscriptionId
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -2,25 +2,34 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
|
||||
import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable';
|
||||
import { useConfigVariableActions } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableActions';
|
||||
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
|
||||
import { SubscriptionInfoRowContainer } from '@/settings/billing/components/internal/SubscriptionInfoRowContainer';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||
import {
|
||||
ENTERPRISE_PLAN_MODAL_ID,
|
||||
EnterprisePlanModal,
|
||||
} from '@/settings/enterprise/components/EnterprisePlanModal';
|
||||
import { REFRESH_ENTERPRISE_VALIDITY_TOKEN } from '@/settings/enterprise/graphql/mutations/refreshEnterpriseValidityToken';
|
||||
import { RELEASE_ENTERPRISE_SERVER_BINDING } from '@/settings/enterprise/graphql/mutations/releaseEnterpriseServerBinding';
|
||||
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 { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
|
||||
import { useLazyQuery, useMutation } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
ENTERPRISE_INSTANCE_TYPE,
|
||||
type EnterpriseInstanceType,
|
||||
} from 'twenty-shared/constants';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
@@ -31,12 +40,15 @@ import {
|
||||
IconKey,
|
||||
IconUser,
|
||||
} from 'twenty-ui/icon';
|
||||
import { H2Title } from 'twenty-ui/typography';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { H2Title } from 'twenty-ui/typography';
|
||||
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
|
||||
|
||||
const RELEASE_ENTERPRISE_BINDING_CONFIRMATION_MODAL_ID =
|
||||
'release-enterprise-binding-confirmation-modal';
|
||||
|
||||
type SettingsEnterpriseProps = {
|
||||
isAdminPanelTab?: boolean;
|
||||
};
|
||||
@@ -108,14 +120,65 @@ export const SettingsEnterprise = ({
|
||||
const [refreshValidityTokenMutation] = useMutation<{
|
||||
refreshEnterpriseValidityToken: boolean;
|
||||
}>(REFRESH_ENTERPRISE_VALIDITY_TOKEN);
|
||||
const [releaseServerBindingMutation] = useMutation<{
|
||||
releaseEnterpriseServerBinding: {
|
||||
isValid: boolean;
|
||||
licensee: string | null;
|
||||
expiresAt: string | null;
|
||||
subscriptionId: string | null;
|
||||
};
|
||||
}>(RELEASE_ENTERPRISE_SERVER_BINDING);
|
||||
const [fetchPortalSession] = useLazyQuery<{
|
||||
enterprisePortalSession: string | null;
|
||||
}>(ENTERPRISE_PORTAL_SESSION);
|
||||
const [isRefreshingToken, setIsRefreshingToken] = useState(false);
|
||||
const [isReleasing, setIsReleasing] = useState(false);
|
||||
const [isBoundToAnotherServer, setIsBoundToAnotherServer] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
||||
const { loadCurrentUser } = useLoadCurrentUser();
|
||||
|
||||
const apolloAdminClient = useApolloAdminClient();
|
||||
const {
|
||||
handleUpdateVariable: updateInstanceTypeVariable,
|
||||
handleDeleteVariable: deleteInstanceTypeVariable,
|
||||
} = useConfigVariableActions('ENTERPRISE_INSTANCE_TYPE');
|
||||
const [instanceType, setInstanceType] = useState<EnterpriseInstanceType>(
|
||||
ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
|
||||
);
|
||||
const [isInstanceTypeFromDb, setIsInstanceTypeFromDb] = useState(false);
|
||||
const [isUpdatingInstanceType, setIsUpdatingInstanceType] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadInstanceType = async () => {
|
||||
try {
|
||||
const { data } = await apolloAdminClient.query<{
|
||||
getDatabaseConfigVariable: {
|
||||
value: unknown;
|
||||
source: string;
|
||||
} | null;
|
||||
}>({
|
||||
query: GET_DATABASE_CONFIG_VARIABLE,
|
||||
variables: { key: 'ENTERPRISE_INSTANCE_TYPE' },
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const variable = data?.getDatabaseConfigVariable;
|
||||
|
||||
setInstanceType(
|
||||
variable?.value === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT
|
||||
? ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT
|
||||
: ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
|
||||
);
|
||||
setIsInstanceTypeFromDb(variable?.source === 'DATABASE');
|
||||
} catch {
|
||||
// Best-effort: the instance-type control simply stays at its default.
|
||||
}
|
||||
};
|
||||
|
||||
loadInstanceType();
|
||||
}, [apolloAdminClient]);
|
||||
|
||||
const hasSignedEnterpriseKey =
|
||||
currentWorkspace?.hasValidSignedEnterpriseKey === true;
|
||||
const hasValidityToken =
|
||||
@@ -203,7 +266,27 @@ export const SettingsEnterprise = ({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isGraphqlErrorOfType(error, 'CONFIG_VARIABLES_IN_DB_DISABLED')) {
|
||||
const isServerBindingRejection =
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER') ||
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_MISSING_SERVER_ID') ||
|
||||
isGraphqlErrorOfType(
|
||||
error,
|
||||
'ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION',
|
||||
) ||
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_DEV_SLOT_IN_USE');
|
||||
|
||||
if (isServerBindingRejection) {
|
||||
setIsBoundToAnotherServer(
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER'),
|
||||
);
|
||||
await loadCurrentUser();
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
options: { duration: 10000 },
|
||||
});
|
||||
} else if (
|
||||
isGraphqlErrorOfType(error, 'CONFIG_VARIABLES_IN_DB_DISABLED')
|
||||
) {
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
options: { duration: 10000 },
|
||||
@@ -263,6 +346,7 @@ export const SettingsEnterprise = ({
|
||||
const { data } = await refreshValidityTokenMutation();
|
||||
|
||||
if (data?.refreshEnterpriseValidityToken === true) {
|
||||
setIsBoundToAnotherServer(false);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Validity token refreshed successfully`,
|
||||
});
|
||||
@@ -272,10 +356,34 @@ export const SettingsEnterprise = ({
|
||||
message: t`Could not refresh validity token. Please contact support.`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error refreshing validity token. Please contact support.`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER')
|
||||
) {
|
||||
setIsBoundToAnotherServer(true);
|
||||
await loadCurrentUser();
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
options: { duration: 10000 },
|
||||
});
|
||||
} else if (
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_MISSING_SERVER_ID') ||
|
||||
isGraphqlErrorOfType(
|
||||
error,
|
||||
'ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION',
|
||||
) ||
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_DEV_SLOT_IN_USE') ||
|
||||
isGraphqlErrorOfType(error, 'ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED')
|
||||
) {
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
options: { duration: 10000 },
|
||||
});
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error refreshing validity token. Please contact support.`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshingToken(false);
|
||||
}
|
||||
@@ -287,6 +395,117 @@ export const SettingsEnterprise = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleReleaseBinding = useCallback(async () => {
|
||||
setIsReleasing(true);
|
||||
|
||||
try {
|
||||
const result = await releaseServerBindingMutation();
|
||||
|
||||
if (result.data?.releaseEnterpriseServerBinding.isValid === true) {
|
||||
setIsBoundToAnotherServer(false);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Enterprise key transferred to this server`,
|
||||
});
|
||||
const { data: statusData } = await fetchSubscriptionStatus();
|
||||
|
||||
setSubscriptionStatus(statusData?.enterpriseSubscriptionStatus ?? null);
|
||||
await loadCurrentUser();
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not transfer the enterprise key. Please contact support.`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isGraphqlErrorOfType(error, 'ENTERPRISE_RELEASE_RATE_LIMITED')) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`You have reached the maximum number of server transfers allowed in the last 30 days for this enterprise key. Please try again later or contact support.`,
|
||||
});
|
||||
} else {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error transferring the enterprise key`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsReleasing(false);
|
||||
}
|
||||
}, [
|
||||
releaseServerBindingMutation,
|
||||
enqueueSuccessSnackBar,
|
||||
enqueueErrorSnackBar,
|
||||
fetchSubscriptionStatus,
|
||||
loadCurrentUser,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleSetInstanceType = useCallback(
|
||||
async (nextInstanceType: EnterpriseInstanceType) => {
|
||||
setIsUpdatingInstanceType(true);
|
||||
const previousInstanceType = instanceType;
|
||||
const previousIsInstanceTypeFromDb = isInstanceTypeFromDb;
|
||||
let instanceUpdateSuccess = false;
|
||||
let tokenRefreshSuccess = false;
|
||||
|
||||
try {
|
||||
await updateInstanceTypeVariable(
|
||||
nextInstanceType,
|
||||
isInstanceTypeFromDb,
|
||||
);
|
||||
instanceUpdateSuccess = true;
|
||||
setInstanceType(nextInstanceType);
|
||||
setIsInstanceTypeFromDb(true);
|
||||
await loadCurrentUser();
|
||||
|
||||
enqueueSuccessSnackBar({
|
||||
message:
|
||||
nextInstanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT
|
||||
? t`Registered as a development instance. This instance will not be billed.`
|
||||
: t`Switched to a production instance.`,
|
||||
});
|
||||
|
||||
await refreshValidityTokenMutation();
|
||||
tokenRefreshSuccess = true;
|
||||
} catch {
|
||||
if (!instanceUpdateSuccess) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not update the instance type`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (instanceUpdateSuccess && !tokenRefreshSuccess) {
|
||||
try {
|
||||
if (previousIsInstanceTypeFromDb) {
|
||||
await updateInstanceTypeVariable(previousInstanceType, true);
|
||||
} else {
|
||||
await deleteInstanceTypeVariable();
|
||||
}
|
||||
setInstanceType(previousInstanceType);
|
||||
setIsInstanceTypeFromDb(previousIsInstanceTypeFromDb);
|
||||
await loadCurrentUser();
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not refresh validity token - reverted the instance type change.`,
|
||||
});
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not refresh validity token and could not revert the instance type change.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsUpdatingInstanceType(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
instanceType,
|
||||
updateInstanceTypeVariable,
|
||||
deleteInstanceTypeVariable,
|
||||
isInstanceTypeFromDb,
|
||||
refreshValidityTokenMutation,
|
||||
loadCurrentUser,
|
||||
enqueueSuccessSnackBar,
|
||||
enqueueErrorSnackBar,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const activateKeySection = (
|
||||
<Section>
|
||||
<H2Title
|
||||
@@ -317,6 +536,69 @@ export const SettingsEnterprise = ({
|
||||
</Section>
|
||||
);
|
||||
|
||||
const transferSection = (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Key in use on another server`}
|
||||
description={t`This enterprise key is already bound to a different server instance. Releasing it here will transfer the license to this server and stop counting seats on the previous one.`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconKey}
|
||||
title={
|
||||
isReleasing
|
||||
? t`Transferring...`
|
||||
: t`Release & transfer to this server`
|
||||
}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
onClick={() =>
|
||||
openModal(RELEASE_ENTERPRISE_BINDING_CONFIRMATION_MODAL_ID)
|
||||
}
|
||||
disabled={isReleasing}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
|
||||
const instanceTypeSection = (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Development instance`}
|
||||
description={
|
||||
instanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT
|
||||
? t`This instance is registered as a development instance and is not billed. A subscription can have a single free development instance in addition to its production one.`
|
||||
: t`Register this server as a free development instance (for a staging or test environment). Development instances unlock enterprise features without being billed, and do not affect your production seat count.`
|
||||
}
|
||||
/>
|
||||
{instanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT ? (
|
||||
<Button
|
||||
title={
|
||||
isUpdatingInstanceType
|
||||
? t`Updating...`
|
||||
: t`Switch to production instance`
|
||||
}
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
handleSetInstanceType(ENTERPRISE_INSTANCE_TYPE.PRODUCTION)
|
||||
}
|
||||
disabled={isUpdatingInstanceType}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
title={
|
||||
isUpdatingInstanceType
|
||||
? t`Updating...`
|
||||
: t`Register as development instance`
|
||||
}
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
handleSetInstanceType(ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT)
|
||||
}
|
||||
disabled={isUpdatingInstanceType}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isStatusLoaded) {
|
||||
return null;
|
||||
@@ -412,6 +694,7 @@ export const SettingsEnterprise = ({
|
||||
)}
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
{isBoundToAnotherServer && transferSection}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage billing information`}
|
||||
@@ -657,10 +940,22 @@ export const SettingsEnterprise = ({
|
||||
);
|
||||
};
|
||||
|
||||
const hasEnterpriseLicense = hasSignedEnterpriseKey || hasValidityToken;
|
||||
|
||||
const innerContent = (
|
||||
<>
|
||||
<EnterprisePlanModal />
|
||||
<ConfirmationModal
|
||||
modalInstanceId={RELEASE_ENTERPRISE_BINDING_CONFIRMATION_MODAL_ID}
|
||||
title={t`Release & transfer enterprise key`}
|
||||
subtitle={t`This enterprise key is currently bound to a different server instance. Transferring it here will release it from the previous server and stop counting seats on it. Are you sure you want to continue?`}
|
||||
confirmButtonText={t`Release & transfer`}
|
||||
confirmButtonAccent="blue"
|
||||
loading={isReleasing}
|
||||
onConfirmClick={handleReleaseBinding}
|
||||
/>
|
||||
{renderContent()}
|
||||
{hasEnterpriseLicense && instanceTypeSection}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user