Skip to content

Commit 8a4bcd1

Browse files
authored
(Billing for self hosts) Tie enterprise key to server (twentyhq#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.
1 parent ed2b2f8 commit 8a4bcd1

54 files changed

Lines changed: 2028 additions & 78 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/twenty-client-sdk/src/metadata/generated/schema.graphql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,6 +1961,7 @@ type ClientConfig {
19611961
isCloudflareIntegrationEnabled: Boolean!
19621962
isClickHouseConfigured: Boolean!
19631963
isWorkspaceSchemaDDLLocked: Boolean!
1964+
enterpriseInstanceType: String!
19641965
maintenance: ClientConfigMaintenanceMode
19651966
}
19661967

@@ -3301,6 +3302,7 @@ type Mutation {
33013302
createFileUpload(filename: String!, size: Float!, fileFolder: FileFolder!, fieldMetadataId: String, fieldMetadataUniversalIdentifier: String): FileUploadTarget!
33023303
completeFileUpload(fileId: String!): FileWithSignedUrl!
33033304
refreshEnterpriseValidityToken: Boolean!
3305+
releaseEnterpriseServerBinding: EnterpriseLicenseInfoDTO!
33043306
setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO!
33053307
uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl!
33063308
uploadAiChatFile(file: Upload!): FileWithSignedUrl!

packages/twenty-client-sdk/src/metadata/generated/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,7 @@ export interface ClientConfig {
15981598
isCloudflareIntegrationEnabled: Scalars['Boolean']
15991599
isClickHouseConfigured: Scalars['Boolean']
16001600
isWorkspaceSchemaDDLLocked: Scalars['Boolean']
1601+
enterpriseInstanceType: Scalars['String']
16011602
maintenance?: ClientConfigMaintenanceMode
16021603
__typename: 'ClientConfig'
16031604
}
@@ -2833,6 +2834,7 @@ export interface Mutation {
28332834
createFileUpload: FileUploadTarget
28342835
completeFileUpload: FileWithSignedUrl
28352836
refreshEnterpriseValidityToken: Scalars['Boolean']
2837+
releaseEnterpriseServerBinding: EnterpriseLicenseInfoDTO
28362838
setEnterpriseKey: EnterpriseLicenseInfoDTO
28372839
uploadEmailAttachmentFile: FileWithSignedUrl
28382840
uploadAiChatFile: FileWithSignedUrl
@@ -4713,6 +4715,7 @@ export interface ClientConfigGenqlSelection{
47134715
isCloudflareIntegrationEnabled?: boolean | number
47144716
isClickHouseConfigured?: boolean | number
47154717
isWorkspaceSchemaDDLLocked?: boolean | number
4718+
enterpriseInstanceType?: boolean | number
47164719
maintenance?: ClientConfigMaintenanceModeGenqlSelection
47174720
__typename?: boolean | number
47184721
__scalar?: boolean | number
@@ -6078,6 +6081,7 @@ export interface MutationGenqlSelection{
60786081
createFileUpload?: (FileUploadTargetGenqlSelection & { __args: {filename: Scalars['String'], size: Scalars['Float'], fileFolder: FileFolder, fieldMetadataId?: (Scalars['String'] | null), fieldMetadataUniversalIdentifier?: (Scalars['String'] | null)} })
60796082
completeFileUpload?: (FileWithSignedUrlGenqlSelection & { __args: {fileId: Scalars['String']} })
60806083
refreshEnterpriseValidityToken?: boolean | number
6084+
releaseEnterpriseServerBinding?: EnterpriseLicenseInfoDTOGenqlSelection
60816085
setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} })
60826086
uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
60836087
uploadAiChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })

packages/twenty-client-sdk/src/metadata/generated/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3821,6 +3821,9 @@ export default {
38213821
"isWorkspaceSchemaDDLLocked": [
38223822
6
38233823
],
3824+
"enterpriseInstanceType": [
3825+
1
3826+
],
38243827
"maintenance": [
38253828
195
38263829
],
@@ -7146,6 +7149,9 @@ export default {
71467149
"refreshEnterpriseValidityToken": [
71477150
6
71487151
],
7152+
"releaseEnterpriseServerBinding": [
7153+
125
7154+
],
71497155
"setEnterpriseKey": [
71507156
125,
71517157
{

packages/twenty-front/src/generated-metadata/graphql.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,7 @@ export type ClientConfig = {
924924
canManageFeatureFlags: Scalars['Boolean']['output'];
925925
captcha: Captcha;
926926
defaultSubdomain?: Maybe<Scalars['String']['output']>;
927+
enterpriseInstanceType: Scalars['String']['output'];
927928
frontDomain: Scalars['String']['output'];
928929
isAttachmentPreviewEnabled: Scalars['Boolean']['output'];
929930
isClickHouseConfigured: Scalars['Boolean']['output'];
@@ -2641,6 +2642,7 @@ export type Mutation = {
26412642
/** @deprecated Use installApplication instead */
26422643
installMarketplaceApp: Scalars['Boolean']['output'];
26432644
refreshEnterpriseValidityToken: Scalars['Boolean']['output'];
2645+
releaseEnterpriseServerBinding: EnterpriseLicenseInfoDto;
26442646
removeQueryFromEventStream: Scalars['Boolean']['output'];
26452647
removeRoleFromAgent: Scalars['Boolean']['output'];
26462648
renameChatThread: AgentChatThread;
@@ -8080,6 +8082,11 @@ export type RefreshEnterpriseValidityTokenMutationVariables = Exact<{ [key: stri
80808082

80818083
export type RefreshEnterpriseValidityTokenMutation = { __typename?: 'Mutation', refreshEnterpriseValidityToken: boolean };
80828084

8085+
export type ReleaseEnterpriseServerBindingMutationVariables = Exact<{ [key: string]: never; }>;
8086+
8087+
8088+
export type ReleaseEnterpriseServerBindingMutation = { __typename?: 'Mutation', releaseEnterpriseServerBinding: { __typename?: 'EnterpriseLicenseInfoDTO', isValid: boolean, licensee?: string | null, expiresAt?: string | null, subscriptionId?: string | null } };
8089+
80838090
export type SetEnterpriseKeyMutationVariables = Exact<{
80848091
enterpriseKey: Scalars['String']['input'];
80858092
}>;
@@ -9016,6 +9023,7 @@ export const DeleteEmailingDomainDocument = {"kind":"Document","definitions":[{"
90169023
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>;
90179024
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>;
90189025
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>;
9026+
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>;
90199027
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>;
90209028
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>;
90219029
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>;

packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
1414
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
1515
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
1616
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
17+
import { enterpriseInstanceTypeState } from '@/client-config/states/enterpriseInstanceTypeState';
1718
import { isEmailingDomainInDemoModeState } from '@/client-config/states/isEmailingDomainInDemoModeState';
1819
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
1920
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
@@ -34,6 +35,7 @@ import { getClientConfig } from '@/client-config/utils/getClientConfig';
3435
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
3536
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
3637
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
38+
import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants';
3739

3840
type UseClientConfigResult = {
3941
data: { clientConfig: ClientConfig } | undefined;
@@ -126,6 +128,10 @@ export const useClientConfig = (): UseClientConfigResult => {
126128

127129
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
128130

131+
const setEnterpriseInstanceType = useSetAtomState(
132+
enterpriseInstanceTypeState,
133+
);
134+
129135
const setAppVersion = useSetAtomState(appVersionState);
130136

131137
const fetchClientConfig = useCallback(async () => {
@@ -210,6 +216,10 @@ export const useClientConfig = (): UseClientConfigResult => {
210216
setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false);
211217
setIsDDLLocked(clientConfig?.isWorkspaceSchemaDDLLocked ?? false);
212218
setMaintenanceMode(clientConfig?.maintenance ?? null);
219+
setEnterpriseInstanceType(
220+
clientConfig?.enterpriseInstanceType ??
221+
ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
222+
);
213223
} catch (err) {
214224
const error =
215225
err instanceof Error ? err : new Error('Failed to fetch client config');
@@ -248,6 +258,7 @@ export const useClientConfig = (): UseClientConfigResult => {
248258
setIsDDLLocked,
249259
setLabPublicFeatureFlags,
250260
setMaintenanceMode,
261+
setEnterpriseInstanceType,
251262
setIsMicrosoftCalendarEnabled,
252263
setIsMicrosoftMessagingEnabled,
253264
setSentryConfig,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
2+
import {
3+
ENTERPRISE_INSTANCE_TYPE,
4+
type EnterpriseInstanceType,
5+
} from 'twenty-shared/constants';
6+
7+
export const enterpriseInstanceTypeState =
8+
createAtomState<EnterpriseInstanceType>({
9+
key: 'enterpriseInstanceTypeState',
10+
defaultValue: ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
11+
});

packages/twenty-front/src/modules/client-config/types/ClientConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type Sentry,
1010
type Support,
1111
} from '~/generated-metadata/graphql';
12+
import { type EnterpriseInstanceType } from 'twenty-shared/constants';
1213
import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig';
1314

1415
export type ClientConfig = {
@@ -45,4 +46,5 @@ export type ClientConfig = {
4546
isTwoFactorAuthenticationEnabled: boolean;
4647
allowRequestsToTwentyIcons: boolean;
4748
maintenance?: ClientConfigMaintenanceMode;
49+
enterpriseInstanceType?: EnterpriseInstanceType;
4850
};

packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
33
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
44

55
import { InformationBannerBillingSubscriptionPaused } from '@/information-banner/components/billing/InformationBannerBillingSubscriptionPaused';
6+
import { InformationBannerNonProductionInstance } from '@/information-banner/components/enterprise/InformationBannerNonProductionInstance';
67
import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod';
78
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
89
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
@@ -63,6 +64,7 @@ export const InformationBannerWrapper = () => {
6364

6465
return (
6566
<StyledInformationBannerWrapper>
67+
<InformationBannerNonProductionInstance />
6668
<InformationBannerMaintenance />
6769
{isAccountSyncEnabled && (
6870
<InformationBannerReconnectAccountInsufficientPermissions />
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { enterpriseInstanceTypeState } from '@/client-config/states/enterpriseInstanceTypeState';
2+
import { InformationBanner } from '@/information-banner/components/InformationBanner';
3+
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
4+
import { t } from '@lingui/core/macro';
5+
import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants';
6+
import { isDefined } from 'twenty-shared/utils';
7+
8+
export const InformationBannerNonProductionInstance = () => {
9+
const enterpriseInstanceType = useAtomStateValue(enterpriseInstanceTypeState);
10+
11+
if (
12+
!isDefined(enterpriseInstanceType) ||
13+
enterpriseInstanceType === ENTERPRISE_INSTANCE_TYPE.PRODUCTION
14+
) {
15+
return null;
16+
}
17+
18+
return (
19+
<InformationBanner
20+
componentInstanceId="information-banner-non-production-instance"
21+
variant="secondary"
22+
message={t`This is a non-production instance.`}
23+
/>
24+
);
25+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { gql } from '@apollo/client';
2+
3+
export const RELEASE_ENTERPRISE_SERVER_BINDING = gql`
4+
mutation ReleaseEnterpriseServerBinding {
5+
releaseEnterpriseServerBinding {
6+
isValid
7+
licensee
8+
expiresAt
9+
subscriptionId
10+
}
11+
}
12+
`;

0 commit comments

Comments
 (0)