From c1da7be6d782cbd17a96dd6031c3946706e0a199 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:07:53 +0100 Subject: [PATCH] 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 --- .../src/generated-metadata/graphql.ts | 248 +++- .../services/__tests__/apollo.factory.test.ts | 3 + .../modules/app/components/SettingsRoutes.tsx | 12 +- .../auth/states/currentWorkspaceState.ts | 2 + .../components/InformationBanner.tsx | 1 - .../components/InformationBannerWrapper.tsx | 2 + .../InformationBannerLegacyEnterpriseKey.tsx | 45 + .../hooks/__mocks__/useFieldMetadataItem.ts | 2 + ...olumnDefinitionsFromObjectMetadata.test.ts | 2 + .../components/SettingsAdminContent.tsx | 16 +- .../components/SettingsAdminTabContent.tsx | 13 + .../constants/SettingsAdminTabs.ts | 1 + .../components/EnterprisePlanModal.tsx | 178 +++ .../refreshEnterpriseValidityToken.ts | 7 + .../graphql/mutations/setEnterpriseKey.ts | 12 + .../queries/enterpriseCheckoutSession.ts | 7 + .../queries/enterprisePortalSession.ts | 7 + .../queries/enterpriseSubscriptionStatus.ts | 14 + ...rmissionsObjectLevelRecordLevelSection.tsx | 27 +- .../ui/layout/tab-list/components/TabList.tsx | 15 +- .../graphql/fragments/userQueryFragment.ts | 2 + .../enterprise/SettingsEnterprise.tsx | 682 +++++++++++ .../src/testing/mock-data/users.ts | 2 + .../clients/generated/metadata/schema.graphql | 23 + .../src/clients/generated/metadata/schema.ts | 68 ++ .../src/clients/generated/metadata/types.ts | 1031 +++++++++-------- .../commands/cron-register-all.command.ts | 14 +- .../commands/database-command.module.ts | 8 +- .../app-token/app-token.entity.ts | 1 + .../core-modules/auth/auth.exception.ts | 3 + .../engine/core-modules/auth/auth.module.ts | 2 + .../enterprise-features-enabled.guard.ts | 10 +- ...auth-graphql-api-exception-handler.util.ts | 1 + .../get-auth-exception-rest-status.util.ts | 1 + .../core-modules/billing/billing.module.ts | 2 + .../services/billing-subscription.service.ts | 8 +- ...se-key-validation-cron-pattern.constant.ts | 2 + .../enterprise-public-key.constant.ts | 26 + ...-token-default-expiration-days.constant.ts | 5 + .../enterprise-key-validation.cron.command.ts | 35 + .../enterprise-key-validation.cron.job.ts | 74 ++ .../dtos/enterprise-license-info.dto.ts | 18 + .../enterprise-subscription-status.dto.ts | 24 + .../dtos/set-enterprise-key.input.ts | 13 + .../enterprise/enterprise-exception.filter.ts | 25 + .../enterprise/enterprise.exception.ts | 38 + .../enterprise/enterprise.module.ts | 25 + .../enterprise/enterprise.resolver.ts | 150 +++ .../__tests__/enterprise-plan.service.spec.ts | 806 +++++++++++++ .../services/enterprise-plan.service.ts | 532 +++++++++ .../types/enterprise-key-payload.type.ts | 19 + .../event-logs/event-logs.module.ts | 2 + .../core-modules/message-queue/jobs.module.ts | 8 +- .../src/engine/core-modules/sso/sso.module.ts | 2 + .../twenty-config/config-variables.ts | 18 + .../twenty-config/twenty-config.service.ts | 4 + .../user-workspace/user-workspace.module.ts | 2 + .../user-workspace/user-workspace.service.ts | 8 + .../workspace/workspace.module.ts | 4 + .../workspace/workspace.resolver.ts | 18 +- .../engine/guards/billing-disabled.guard.ts | 16 + .../row-level-permission.module.ts | 2 + ...evel-permission-predicate-group.service.ts | 10 +- .../row-level-permission-predicate.service.ts | 10 +- .../core/utils/seed-core-schema.util.ts | 1 + .../twenty-shared/src/types/SettingsPath.ts | 2 + packages/twenty-website/package.json | 1 + .../app/(public)/enterprise/activate/page.tsx | 172 +++ .../src/app/api/enterprise/activate/route.ts | 62 + .../src/app/api/enterprise/checkout/route.ts | 47 + .../src/app/api/enterprise/portal/route.ts | 57 + .../src/app/api/enterprise/seats/route.ts | 89 ++ .../src/app/api/enterprise/status/route.ts | 58 + .../src/app/api/enterprise/validate/route.ts | 73 ++ .../src/shared/enterprise/enterprise-jwt.ts | 188 +++ .../src/shared/enterprise/stripe-client.ts | 34 + 76 files changed, 4614 insertions(+), 538 deletions(-) create mode 100644 packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx create mode 100644 packages/twenty-front/src/modules/settings/enterprise/components/EnterprisePlanModal.tsx create mode 100644 packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/refreshEnterpriseValidityToken.ts create mode 100644 packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/setEnterpriseKey.ts create mode 100644 packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseCheckoutSession.ts create mode 100644 packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterprisePortalSession.ts create mode 100644 packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseSubscriptionStatus.ts create mode 100644 packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/constants/enterprise-key-validation-cron-pattern.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/constants/enterprise-public-key.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/constants/enterprise-validity-token-default-expiration-days.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/dtos/enterprise-license-info.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/dtos/enterprise-subscription-status.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/dtos/set-enterprise-key.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/enterprise-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/enterprise.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts create mode 100644 packages/twenty-server/src/engine/guards/billing-disabled.guard.ts create mode 100644 packages/twenty-website/src/app/(public)/enterprise/activate/page.tsx create mode 100644 packages/twenty-website/src/app/api/enterprise/activate/route.ts create mode 100644 packages/twenty-website/src/app/api/enterprise/checkout/route.ts create mode 100644 packages/twenty-website/src/app/api/enterprise/portal/route.ts create mode 100644 packages/twenty-website/src/app/api/enterprise/seats/route.ts create mode 100644 packages/twenty-website/src/app/api/enterprise/status/route.ts create mode 100644 packages/twenty-website/src/app/api/enterprise/validate/route.ts create mode 100644 packages/twenty-website/src/shared/enterprise/enterprise-jwt.ts create mode 100644 packages/twenty-website/src/shared/enterprise/stripe-client.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 7cb42f737fa..bcdfe78e5ab 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1597,6 +1597,24 @@ export enum EngineComponentKey { USE_AS_DRAFT_WORKFLOW_VERSION = 'USE_AS_DRAFT_WORKFLOW_VERSION' } +export type EnterpriseLicenseInfoDto = { + __typename?: 'EnterpriseLicenseInfoDTO'; + expiresAt?: Maybe; + isValid: Scalars['Boolean']; + licensee?: Maybe; + subscriptionId?: Maybe; +}; + +export type EnterpriseSubscriptionStatusDto = { + __typename?: 'EnterpriseSubscriptionStatusDTO'; + cancelAt?: Maybe; + currentPeriodEnd?: Maybe; + expiresAt?: Maybe; + isCancellationScheduled: Scalars['Boolean']; + licensee?: Maybe; + status: Scalars['String']; +}; + export type EventLogDateRangeInput = { end?: InputMaybe; start?: InputMaybe; @@ -2483,6 +2501,7 @@ export type Mutation = { initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning; installApplication: Scalars['Boolean']; installMarketplaceApp: Scalars['Boolean']; + refreshEnterpriseValidityToken: Scalars['Boolean']; removeQueryFromEventStream: Scalars['Boolean']; removeRoleFromAgent: Scalars['Boolean']; renewApplicationToken: ApplicationTokenPair; @@ -2497,6 +2516,7 @@ export type Mutation = { saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess; sendInvitations: SendInvitations; setAdminAiModelEnabled: Scalars['Boolean']; + setEnterpriseKey: EnterpriseLicenseInfoDto; setMeteredSubscriptionPrice: BillingUpdate; signIn: AvailableWorkspacesAndAccessTokens; signUp: AvailableWorkspacesAndAccessTokens; @@ -3160,6 +3180,11 @@ export type MutationSetAdminAiModelEnabledArgs = { }; +export type MutationSetEnterpriseKeyArgs = { + enterpriseKey: Scalars['String']; +}; + + export type MutationSetMeteredSubscriptionPriceArgs = { priceId: Scalars['String']; }; @@ -3975,6 +4000,9 @@ export type Query = { commandMenuItems: Array; currentUser: User; currentWorkspace: Workspace; + enterpriseCheckoutSession?: Maybe; + enterprisePortalSession?: Maybe; + enterpriseSubscriptionStatus?: Maybe; eventLogs: EventLogQueryResult; field: Field; fields: FieldConnection; @@ -4117,6 +4145,16 @@ export type QueryCommandMenuItemArgs = { }; +export type QueryEnterpriseCheckoutSessionArgs = { + billingInterval?: InputMaybe; +}; + + +export type QueryEnterprisePortalSessionArgs = { + returnUrlPath?: InputMaybe; +}; + + export type QueryEventLogsArgs = { input: EventLogQueryInput; }; @@ -5543,6 +5581,8 @@ export type Workspace = { fastModel: Scalars['String']; featureFlags?: Maybe>; hasValidEnterpriseKey: Scalars['Boolean']; + hasValidEnterpriseValidityToken: Scalars['Boolean']; + hasValidSignedEnterpriseKey: Scalars['Boolean']; id: Scalars['UUID']; inviteHash?: Maybe; isCustomDomainEnabled: Scalars['Boolean']; @@ -6823,6 +6863,37 @@ export type GetEmailingDomainsQueryVariables = Exact<{ [key: string]: never; }>; export type GetEmailingDomainsQuery = { __typename?: 'Query', getEmailingDomains: Array<{ __typename?: 'EmailingDomain', id: string, domain: string, driver: EmailingDomainDriver, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string, verificationRecords?: Array<{ __typename?: 'VerificationRecord', type: string, key: string, value: string, priority?: number | null }> | null }> }; +export type RefreshEnterpriseValidityTokenMutationVariables = Exact<{ [key: string]: never; }>; + + +export type RefreshEnterpriseValidityTokenMutation = { __typename?: 'Mutation', refreshEnterpriseValidityToken: boolean }; + +export type SetEnterpriseKeyMutationVariables = Exact<{ + enterpriseKey: Scalars['String']; +}>; + + +export type SetEnterpriseKeyMutation = { __typename?: 'Mutation', setEnterpriseKey: { __typename?: 'EnterpriseLicenseInfoDTO', isValid: boolean, licensee?: string | null, expiresAt?: string | null, subscriptionId?: string | null } }; + +export type EnterpriseCheckoutSessionQueryVariables = Exact<{ + billingInterval?: InputMaybe; +}>; + + +export type EnterpriseCheckoutSessionQuery = { __typename?: 'Query', enterpriseCheckoutSession?: string | null }; + +export type EnterprisePortalSessionQueryVariables = Exact<{ + returnUrlPath?: InputMaybe; +}>; + + +export type EnterprisePortalSessionQuery = { __typename?: 'Query', enterprisePortalSession?: string | null }; + +export type EnterpriseSubscriptionStatusQueryVariables = Exact<{ [key: string]: never; }>; + + +export type EnterpriseSubscriptionStatusQuery = { __typename?: 'Query', enterpriseSubscriptionStatus?: { __typename?: 'EnterpriseSubscriptionStatusDTO', status: string, licensee?: string | null, expiresAt?: string | null, cancelAt?: string | null, currentPeriodEnd?: string | null, isCancellationScheduled: boolean } | null }; + export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{ input: UpdateLabPublicFeatureFlagInput; }>; @@ -6991,7 +7062,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, autoEnableNewAiModels: boolean, disabledAiModelIds?: Array | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidEnterpriseKey: boolean, hasValidSignedEnterpriseKey: boolean, hasValidEnterpriseValidityToken: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, autoEnableNewAiModels: boolean, disabledAiModelIds?: Array | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -7010,7 +7081,7 @@ export type DeleteUserWorkspaceMutation = { __typename?: 'Mutation', deleteUserF export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, autoEnableNewAiModels: boolean, disabledAiModelIds?: Array | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, userWorkspaceId?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, userWorkspaceId?: string | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodSummary', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, customDomain?: string | null, hasValidEnterpriseKey: boolean, hasValidSignedEnterpriseKey: boolean, hasValidEnterpriseValidityToken: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, autoEnableNewAiModels: boolean, disabledAiModelIds?: Array | null, enabledAiModelIds?: Array | null, useRecommendedModels: boolean, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -8329,6 +8400,8 @@ export const UserQueryFragmentFragmentDoc = gql` subdomain customDomain hasValidEnterpriseKey + hasValidSignedEnterpriseKey + hasValidEnterpriseValidityToken workspaceCustomApplication { id } @@ -14525,6 +14598,177 @@ export function useGetEmailingDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHoo export type GetEmailingDomainsQueryHookResult = ReturnType; export type GetEmailingDomainsLazyQueryHookResult = ReturnType; export type GetEmailingDomainsQueryResult = Apollo.QueryResult; +export const RefreshEnterpriseValidityTokenDocument = gql` + mutation RefreshEnterpriseValidityToken { + refreshEnterpriseValidityToken +} + `; +export type RefreshEnterpriseValidityTokenMutationFn = Apollo.MutationFunction; + +/** + * __useRefreshEnterpriseValidityTokenMutation__ + * + * To run a mutation, you first call `useRefreshEnterpriseValidityTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRefreshEnterpriseValidityTokenMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [refreshEnterpriseValidityTokenMutation, { data, loading, error }] = useRefreshEnterpriseValidityTokenMutation({ + * variables: { + * }, + * }); + */ +export function useRefreshEnterpriseValidityTokenMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RefreshEnterpriseValidityTokenDocument, options); + } +export type RefreshEnterpriseValidityTokenMutationHookResult = ReturnType; +export type RefreshEnterpriseValidityTokenMutationResult = Apollo.MutationResult; +export type RefreshEnterpriseValidityTokenMutationOptions = Apollo.BaseMutationOptions; +export const SetEnterpriseKeyDocument = gql` + mutation SetEnterpriseKey($enterpriseKey: String!) { + setEnterpriseKey(enterpriseKey: $enterpriseKey) { + isValid + licensee + expiresAt + subscriptionId + } +} + `; +export type SetEnterpriseKeyMutationFn = Apollo.MutationFunction; + +/** + * __useSetEnterpriseKeyMutation__ + * + * To run a mutation, you first call `useSetEnterpriseKeyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSetEnterpriseKeyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [setEnterpriseKeyMutation, { data, loading, error }] = useSetEnterpriseKeyMutation({ + * variables: { + * enterpriseKey: // value for 'enterpriseKey' + * }, + * }); + */ +export function useSetEnterpriseKeyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SetEnterpriseKeyDocument, options); + } +export type SetEnterpriseKeyMutationHookResult = ReturnType; +export type SetEnterpriseKeyMutationResult = Apollo.MutationResult; +export type SetEnterpriseKeyMutationOptions = Apollo.BaseMutationOptions; +export const EnterpriseCheckoutSessionDocument = gql` + query EnterpriseCheckoutSession($billingInterval: String) { + enterpriseCheckoutSession(billingInterval: $billingInterval) +} + `; + +/** + * __useEnterpriseCheckoutSessionQuery__ + * + * To run a query within a React component, call `useEnterpriseCheckoutSessionQuery` and pass it any options that fit your needs. + * When your component renders, `useEnterpriseCheckoutSessionQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useEnterpriseCheckoutSessionQuery({ + * variables: { + * billingInterval: // value for 'billingInterval' + * }, + * }); + */ +export function useEnterpriseCheckoutSessionQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(EnterpriseCheckoutSessionDocument, options); + } +export function useEnterpriseCheckoutSessionLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(EnterpriseCheckoutSessionDocument, options); + } +export type EnterpriseCheckoutSessionQueryHookResult = ReturnType; +export type EnterpriseCheckoutSessionLazyQueryHookResult = ReturnType; +export type EnterpriseCheckoutSessionQueryResult = Apollo.QueryResult; +export const EnterprisePortalSessionDocument = gql` + query EnterprisePortalSession($returnUrlPath: String) { + enterprisePortalSession(returnUrlPath: $returnUrlPath) +} + `; + +/** + * __useEnterprisePortalSessionQuery__ + * + * To run a query within a React component, call `useEnterprisePortalSessionQuery` and pass it any options that fit your needs. + * When your component renders, `useEnterprisePortalSessionQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useEnterprisePortalSessionQuery({ + * variables: { + * returnUrlPath: // value for 'returnUrlPath' + * }, + * }); + */ +export function useEnterprisePortalSessionQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(EnterprisePortalSessionDocument, options); + } +export function useEnterprisePortalSessionLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(EnterprisePortalSessionDocument, options); + } +export type EnterprisePortalSessionQueryHookResult = ReturnType; +export type EnterprisePortalSessionLazyQueryHookResult = ReturnType; +export type EnterprisePortalSessionQueryResult = Apollo.QueryResult; +export const EnterpriseSubscriptionStatusDocument = gql` + query EnterpriseSubscriptionStatus { + enterpriseSubscriptionStatus { + status + licensee + expiresAt + cancelAt + currentPeriodEnd + isCancellationScheduled + } +} + `; + +/** + * __useEnterpriseSubscriptionStatusQuery__ + * + * To run a query within a React component, call `useEnterpriseSubscriptionStatusQuery` and pass it any options that fit your needs. + * When your component renders, `useEnterpriseSubscriptionStatusQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useEnterpriseSubscriptionStatusQuery({ + * variables: { + * }, + * }); + */ +export function useEnterpriseSubscriptionStatusQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(EnterpriseSubscriptionStatusDocument, options); + } +export function useEnterpriseSubscriptionStatusLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(EnterpriseSubscriptionStatusDocument, options); + } +export type EnterpriseSubscriptionStatusQueryHookResult = ReturnType; +export type EnterpriseSubscriptionStatusLazyQueryHookResult = ReturnType; +export type EnterpriseSubscriptionStatusQueryResult = Apollo.QueryResult; export const UpdateLabPublicFeatureFlagDocument = gql` mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) { updateLabPublicFeatureFlag(input: $input) { diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts index 2ab887ee2cd..4ac58703266 100644 --- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -58,6 +58,9 @@ const mockWorkspace = { isPasswordAuthBypassEnabled: false, isMicrosoftAuthBypassEnabled: false, hasValidEnterpriseKey: false, + hasActivatedAndValidEnterpriseKey: false, + hasValidSignedEnterpriseKey: false, + hasValidEnterpriseValidityToken: false, subdomain: 'test', customDomain: 'test.com', workspaceUrls: { diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 8a940c97cfa..0f33609edb0 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -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 && ( <> } /> + + } + /> } diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 9f3067bba89..9b70764f3ed 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -27,6 +27,8 @@ export type CurrentWorkspace = Pick< | 'isPasswordAuthBypassEnabled' | 'isCustomDomainEnabled' | 'hasValidEnterpriseKey' + | 'hasValidSignedEnterpriseKey' + | 'hasValidEnterpriseValidityToken' | 'subdomain' | 'customDomain' | 'workspaceUrls' diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx index 57dce27f327..d4ced55118d 100644 --- a/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/InformationBanner.tsx @@ -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` diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx index aaa5492fef8..265752ce51d 100644 --- a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx @@ -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 ( + {isAccountSyncEnabled && ( )} diff --git a/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx new file mode 100644 index 00000000000..f2f2d81cc4c --- /dev/null +++ b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx @@ -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 ( + + navigate(getSettingsPath(SettingsPath.AdminPanelEnterprise)) + } + onClose={() => setInformationBannerIsOpen(false)} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts index aa1701e8e87..5dd088d6305 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts @@ -181,6 +181,8 @@ export const responseData = { activationStatus: 'active', isPublicInviteLinkEnabled: false, hasValidEnterpriseKey: false, + hasValidSignedEnterpriseKey: false, + hasValidEnterpriseValidityToken: false, isGoogleAuthEnabled: true, isMicrosoftAuthEnabled: false, isPasswordAuthEnabled: true, diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts index 95fa2f72730..a9f28316bb6 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromObjectMetadata.test.ts @@ -33,6 +33,8 @@ describe('useColumnDefinitionsFromObjectMetadata', () => { subdomain: 'test', activationStatus: WorkspaceActivationStatus.ACTIVE, hasValidEnterpriseKey: false, + hasValidSignedEnterpriseKey: false, + hasValidEnterpriseValidityToken: false, metadataVersion: 1, isPublicInviteLinkEnabled: false, isGoogleAuthEnabled: true, diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminContent.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminContent.tsx index 7ca51933284..b4ae97cc4e0 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminContent.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminContent.tsx @@ -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 ( diff --git a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminTabContent.tsx b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminTabContent.tsx index 2063680883d..884d479c3fb 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminTabContent.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminTabContent.tsx @@ -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 ; case SETTINGS_ADMIN_TABS.HEALTH_STATUS: return ; + case SETTINGS_ADMIN_TABS.ENTERPRISE: + return ( + }> + + + ); default: return null; } diff --git a/packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminTabs.ts b/packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminTabs.ts index 3cf4c9d64a4..d8e5d2b60f0 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminTabs.ts +++ b/packages/twenty-front/src/modules/settings/admin-panel/constants/SettingsAdminTabs.ts @@ -4,4 +4,5 @@ export const SETTINGS_ADMIN_TABS = { AI: 'ai', CONFIG_VARIABLES: 'config-variables', HEALTH_STATUS: 'health-status', + ENTERPRISE: 'enterprise', }; diff --git a/packages/twenty-front/src/modules/settings/enterprise/components/EnterprisePlanModal.tsx b/packages/twenty-front/src/modules/settings/enterprise/components/EnterprisePlanModal.tsx new file mode 100644 index 00000000000..b0ec5ec5836 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/enterprise/components/EnterprisePlanModal.tsx @@ -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('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 ( + + + {t`Get Enterprise`} + {t`Enjoy a 30-day free trial`} + + + + {`$${price}`} + {priceUnit} + + + {benefits.map((benefit) => ( + {benefit} + ))} + + + + + setSelectedInterval('monthly')} + > + {t`Monthly subscription`} + + setSelectedInterval('yearly')} + > + {t`Yearly subscription`} + + + + isLoading && } + disabled={isLoading} + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/refreshEnterpriseValidityToken.ts b/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/refreshEnterpriseValidityToken.ts new file mode 100644 index 00000000000..41c05fa7690 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/refreshEnterpriseValidityToken.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const REFRESH_ENTERPRISE_VALIDITY_TOKEN = gql` + mutation RefreshEnterpriseValidityToken { + refreshEnterpriseValidityToken + } +`; diff --git a/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/setEnterpriseKey.ts b/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/setEnterpriseKey.ts new file mode 100644 index 00000000000..4603befb694 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/setEnterpriseKey.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseCheckoutSession.ts b/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseCheckoutSession.ts new file mode 100644 index 00000000000..9eee1314932 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseCheckoutSession.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const ENTERPRISE_CHECKOUT_SESSION = gql` + query EnterpriseCheckoutSession($billingInterval: String) { + enterpriseCheckoutSession(billingInterval: $billingInterval) + } +`; diff --git a/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterprisePortalSession.ts b/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterprisePortalSession.ts new file mode 100644 index 00000000000..51180557a9b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterprisePortalSession.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const ENTERPRISE_PORTAL_SESSION = gql` + query EnterprisePortalSession($returnUrlPath: String) { + enterprisePortalSession(returnUrlPath: $returnUrlPath) + } +`; diff --git a/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseSubscriptionStatus.ts b/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseSubscriptionStatus.ts new file mode 100644 index 00000000000..709bed3d153 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/enterprise/graphql/queries/enterpriseSubscriptionStatus.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const ENTERPRISE_SUBSCRIPTION_STATUS = gql` + query EnterpriseSubscriptionStatus { + enterpriseSubscriptionStatus { + status + licensee + expiresAt + cancelAt + currentPeriodEnd + isCancellationScheduled + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelSection.tsx index 258dacaf888..b555ca6d41b 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelSection.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelSection.tsx @@ -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 = ({ navigateSettings(SettingsPath.Billing)} - /> - ) + + + +
+ Next steps: +
    +
  1. Copy the enterprise key above
  2. +
  3. + Open your Twenty self-hosted instance Settings → + Enterprise +
  4. +
  5. Paste the key and click Activate
  6. +
+
+ + )} + + + ); +} diff --git a/packages/twenty-website/src/app/api/enterprise/activate/route.ts b/packages/twenty-website/src/app/api/enterprise/activate/route.ts new file mode 100644 index 00000000000..eb629cb6ca9 --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/activate/route.ts @@ -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' } }, + ); + } +} diff --git a/packages/twenty-website/src/app/api/enterprise/checkout/route.ts b/packages/twenty-website/src/app/api/enterprise/checkout/route.ts new file mode 100644 index 00000000000..302a95f39fd --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/checkout/route.ts @@ -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' } }, + ); + } +} diff --git a/packages/twenty-website/src/app/api/enterprise/portal/route.ts b/packages/twenty-website/src/app/api/enterprise/portal/route.ts new file mode 100644 index 00000000000..f9e4ac3a1aa --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/portal/route.ts @@ -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' } }, + ); + } +} diff --git a/packages/twenty-website/src/app/api/enterprise/seats/route.ts b/packages/twenty-website/src/app/api/enterprise/seats/route.ts new file mode 100644 index 00000000000..367bcd114d7 --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/seats/route.ts @@ -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' } }, + ); + } +} diff --git a/packages/twenty-website/src/app/api/enterprise/status/route.ts b/packages/twenty-website/src/app/api/enterprise/status/route.ts new file mode 100644 index 00000000000..fb4006df598 --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/status/route.ts @@ -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' } }, + ); + } +} diff --git a/packages/twenty-website/src/app/api/enterprise/validate/route.ts b/packages/twenty-website/src/app/api/enterprise/validate/route.ts new file mode 100644 index 00000000000..c020e9ed3fa --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/validate/route.ts @@ -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' } }, + ); + } +} diff --git a/packages/twenty-website/src/shared/enterprise/enterprise-jwt.ts b/packages/twenty-website/src/shared/enterprise/enterprise-jwt.ts new file mode 100644 index 00000000000..168ebf4fd25 --- /dev/null +++ b/packages/twenty-website/src/shared/enterprise/enterprise-jwt.ts @@ -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, + 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 = >( + 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(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'); +}; diff --git a/packages/twenty-website/src/shared/enterprise/stripe-client.ts b/packages/twenty-website/src/shared/enterprise/stripe-client.ts new file mode 100644 index 00000000000..133801c30e1 --- /dev/null +++ b/packages/twenty-website/src/shared/enterprise/stripe-client.ts @@ -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; +};