diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 87ce76551..3f79ef0a3 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -179,6 +179,7 @@ export type ClientConfig = { frontDomain: Scalars['String']; isEmailVerificationRequired: Scalars['Boolean']; isMultiWorkspaceEnabled: Scalars['Boolean']; + publicFeatureFlags: Array; sentry: Sentry; signInPrefilled: Scalars['Boolean']; support: Support; @@ -530,6 +531,7 @@ export type Mutation = { switchWorkspace: PublicWorkspaceDataOutput; track: Analytics; updateBillingSubscription: UpdateBillingEntity; + updateLabPublicFeatureFlag: Scalars['Boolean']; updateOneField: Field; updateOneObject: Object; updateOneServerlessFunction: ServerlessFunction; @@ -747,6 +749,11 @@ export type MutationTrackArgs = { }; +export type MutationUpdateLabPublicFeatureFlagArgs = { + input: UpdateLabPublicFeatureFlagInput; +}; + + export type MutationUpdateOneFieldArgs = { input: UpdateOneFieldMetadataInput; }; @@ -890,6 +897,19 @@ export type ProductPricesEntity = { totalNumberOfPrices: Scalars['Int']; }; +export type PublicFeatureFlag = { + __typename?: 'PublicFeatureFlag'; + key: FeatureFlagKey; + metadata: PublicFeatureFlagMetadata; +}; + +export type PublicFeatureFlagMetadata = { + __typename?: 'PublicFeatureFlagMetadata'; + description: Scalars['String']; + imagePath: Scalars['String']; + label: Scalars['String']; +}; + export type PublicWorkspaceDataOutput = { __typename?: 'PublicWorkspaceDataOutput'; authProviders: AuthProviders; @@ -1358,6 +1378,11 @@ export type UpdateFieldInput = { settings?: InputMaybe; }; +export type UpdateLabPublicFeatureFlagInput = { + publicFeatureFlag: Scalars['String']; + value: Scalars['Boolean']; +}; + export type UpdateObjectPayload = { description?: InputMaybe; icon?: InputMaybe; @@ -2103,7 +2128,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'TrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'TrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -2126,6 +2151,13 @@ export type UserLookupAdminPanelMutationVariables = Exact<{ export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } }; +export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{ + input: UpdateLabPublicFeatureFlagInput; +}>; + + +export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: boolean }; + export type CreateOidcIdentityProviderMutationVariables = Exact<{ input: SetupOidcSsoInput; }>; @@ -3615,6 +3647,14 @@ export const GetClientConfigDocument = gql` } chromeExtensionId canManageFeatureFlags + publicFeatureFlags { + key + metadata { + label + description + imagePath + } + } } } `; @@ -3769,6 +3809,37 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo export type UserLookupAdminPanelMutationHookResult = ReturnType; export type UserLookupAdminPanelMutationResult = Apollo.MutationResult; export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions; +export const UpdateLabPublicFeatureFlagDocument = gql` + mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) { + updateLabPublicFeatureFlag(input: $input) +} + `; +export type UpdateLabPublicFeatureFlagMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateLabPublicFeatureFlagMutation__ + * + * To run a mutation, you first call `useUpdateLabPublicFeatureFlagMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateLabPublicFeatureFlagMutation` 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 [updateLabPublicFeatureFlagMutation, { data, loading, error }] = useUpdateLabPublicFeatureFlagMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateLabPublicFeatureFlagMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateLabPublicFeatureFlagDocument, options); + } +export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType; +export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult; +export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions; export const CreateOidcIdentityProviderDocument = gql` mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { createOIDCIdentityProvider(input: $input) { diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 99ad41ade..4f8c3959b 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -253,6 +253,12 @@ const SettingsAdminContent = lazy(() => ), ); +const SettingsLab = lazy(() => + import('~/pages/settings/lab/SettingsLab').then((module) => ({ + default: module.SettingsLab, + })), +); + type SettingsRoutesProps = { isBillingEnabled?: boolean; isServerlessFunctionSettingsEnabled?: boolean; @@ -379,6 +385,7 @@ export const SettingsRoutes = ({ /> )} + } /> ); diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 449ae0d3e..64171344e 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -10,6 +10,7 @@ import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; import { sentryConfigState } from '@/client-config/states/sentryConfigState'; import { supportChatState } from '@/client-config/states/supportChatState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; @@ -52,6 +53,10 @@ export const ClientConfigProviderEffect = () => { canManageFeatureFlagsState, ); + const setLabPublicFeatureFlags = useSetRecoilState( + labPublicFeatureFlagsState, + ); + const { data, loading, error } = useGetClientConfigQuery({ skip: clientConfigApiStatus.isLoaded, }); @@ -117,6 +122,7 @@ export const ClientConfigProviderEffect = () => { frontDomain: data?.clientConfig?.frontDomain, }); setCanManageFeatureFlags(data?.clientConfig?.canManageFeatureFlags); + setLabPublicFeatureFlags(data?.clientConfig?.publicFeatureFlags); }, [ data, setIsDebugMode, @@ -136,6 +142,7 @@ export const ClientConfigProviderEffect = () => { setDomainConfiguration, setAuthProviders, setCanManageFeatureFlags, + setLabPublicFeatureFlags, ]); return <>; diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index fa6a0a669..560c237b5 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -48,6 +48,14 @@ export const GET_CLIENT_CONFIG = gql` } chromeExtensionId canManageFeatureFlags + publicFeatureFlags { + key + metadata { + label + description + imagePath + } + } } } `; diff --git a/packages/twenty-front/src/modules/client-config/states/labPublicFeatureFlagsState.ts b/packages/twenty-front/src/modules/client-config/states/labPublicFeatureFlagsState.ts new file mode 100644 index 000000000..f544ed1cd --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/labPublicFeatureFlagsState.ts @@ -0,0 +1,7 @@ +import { atom } from 'recoil'; +import { PublicFeatureFlag } from '~/generated/graphql'; + +export const labPublicFeatureFlagsState = atom({ + key: 'labPublicFeatureFlagsState', + default: [], +}); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 22e3b1803..32113d19c 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -8,6 +8,7 @@ import { IconComponent, IconCurrencyDollar, IconDoorEnter, + IconFlask, IconFunction, IconHierarchy2, IconKey, @@ -22,6 +23,7 @@ import { import { useAuth } from '@/auth/hooks/useAuth'; import { currentUserState } from '@/auth/states/currentUserState'; import { billingState } from '@/client-config/states/billingState'; +import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem'; import { SettingsPath } from '@/types/SettingsPath'; @@ -64,6 +66,7 @@ export const SettingsNavigationDrawerItems = () => { const currentUser = useRecoilValue(currentUserState); const isAdminPageEnabled = currentUser?.canImpersonate; + const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState); // TODO: Refactor this part to only have arrays of navigation items const currentPathName = useLocation().pathname; @@ -200,6 +203,13 @@ export const SettingsNavigationDrawerItems = () => { Icon={IconServer} /> )} + {labPublicFeatureFlags?.length > 0 && ( + + )} ` + align-self: ${({ toggleCentered }) => + toggleCentered ? 'center' : 'flex-start'}; margin-left: auto; `; @@ -40,6 +44,7 @@ type SettingsOptionCardContentToggleProps = { divider?: boolean; disabled?: boolean; advancedMode?: boolean; + toggleCentered?: boolean; checked: boolean; onChange: (checked: boolean) => void; }; @@ -51,6 +56,7 @@ export const SettingsOptionCardContentToggle = ({ divider, disabled = false, advancedMode = false, + toggleCentered = true, checked, onChange, }: SettingsOptionCardContentToggleProps) => { @@ -83,6 +89,7 @@ export const SettingsOptionCardContentToggle = ({ disabled={disabled} toggleSize="small" color={advancedMode ? theme.color.yellow : theme.color.blue} + toggleCentered={toggleCentered} /> {divider && } diff --git a/packages/twenty-front/src/modules/settings/lab/components/SettingsLabContent.tsx b/packages/twenty-front/src/modules/settings/lab/components/SettingsLabContent.tsx new file mode 100644 index 000000000..23d1ade84 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/lab/components/SettingsLabContent.tsx @@ -0,0 +1,84 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; +import { useLabPublicFeatureFlags } from '@/settings/lab/hooks/useLabPublicFeatureFlags'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { Card, MOBILE_VIEWPORT } from 'twenty-ui'; +import { FeatureFlagKey } from '~/generated/graphql'; + +const StyledCardGrid = styled.div` + display: grid; + gap: ${({ theme }) => theme.spacing(4)}; + grid-template-columns: 1fr; + + & > *:not(:first-child) { + grid-column: span 1; + } + + @media (min-width: ${MOBILE_VIEWPORT}px) { + grid-template-columns: repeat(2, 1fr); + + & > *:first-child { + grid-column: 1 / -1; + } + } +`; + +const StyledImage = styled.img<{ isFirstCard: boolean }>` + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + height: ${({ isFirstCard }) => (isFirstCard ? '240px' : '120px')}; + width: 100%; +`; + +const StyledFallbackDiv = styled.div<{ isFirstCard: boolean }>` + background-color: ${({ theme }) => theme.background.tertiary}; + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + height: ${({ isFirstCard }) => (isFirstCard ? '240px' : '120px')}; + width: 100%; +`; + +export const SettingsLabContent = () => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const { labPublicFeatureFlags, handleLabPublicFeatureFlagUpdate } = + useLabPublicFeatureFlags(); + const [hasImageLoadingError, setHasImageLoadingError] = useState< + Record + >({}); + + const handleToggle = async (key: FeatureFlagKey, value: boolean) => { + await handleLabPublicFeatureFlagUpdate(key, value); + }; + + const handleImageError = (key: string) => { + setHasImageLoadingError((prev) => ({ ...prev, [key]: true })); + }; + + return ( + currentWorkspace?.id && ( + + {labPublicFeatureFlags.map((flag, index) => ( + + {flag.metadata.imagePath && !hasImageLoadingError[flag.key] ? ( + handleImageError(flag.key)} + /> + ) : ( + + )} + handleToggle(flag.key, value)} + toggleCentered={false} + /> + + ))} + + ) + ); +}; diff --git a/packages/twenty-front/src/modules/settings/lab/graphql/mutations/updateLabPublicFeatureFlag.ts b/packages/twenty-front/src/modules/settings/lab/graphql/mutations/updateLabPublicFeatureFlag.ts new file mode 100644 index 000000000..622d6a0e0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/lab/graphql/mutations/updateLabPublicFeatureFlag.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_LAB_PUBLIC_FEATURE_FLAG = gql` + mutation UpdateLabPublicFeatureFlag( + $input: UpdateLabPublicFeatureFlagInput! + ) { + updateLabPublicFeatureFlag(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/lab/hooks/useLabPublicFeatureFlags.ts b/packages/twenty-front/src/modules/settings/lab/hooks/useLabPublicFeatureFlags.ts new file mode 100644 index 000000000..635dd129e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/lab/hooks/useLabPublicFeatureFlags.ts @@ -0,0 +1,66 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; +import { useState } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-ui'; +import { + FeatureFlagKey, + useUpdateLabPublicFeatureFlagMutation, +} from '~/generated/graphql'; + +export const useLabPublicFeatureFlags = () => { + const [error, setError] = useState(null); + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState); + + const [updateLabPublicFeatureFlag] = useUpdateLabPublicFeatureFlagMutation(); + + const handleLabPublicFeatureFlagUpdate = async ( + publicFeatureFlag: FeatureFlagKey, + value: boolean, + ) => { + if (!isDefined(currentWorkspace)) { + setError('No workspace selected'); + return false; + } + + setError(null); + + const response = await updateLabPublicFeatureFlag({ + variables: { + input: { + publicFeatureFlag, + value, + }, + }, + onError: (error) => { + setError(error.message); + }, + }); + + if (isDefined(response.data)) { + setCurrentWorkspace({ + ...currentWorkspace, + featureFlags: currentWorkspace.featureFlags?.map((flag) => + flag.key === publicFeatureFlag ? { ...flag, value } : flag, + ), + }); + } + + return !!response.data; + }; + + return { + labPublicFeatureFlags: labPublicFeatureFlags.map((flag) => ({ + ...flag, + value: + currentWorkspace?.featureFlags?.find( + (workspaceFlag) => workspaceFlag.key === flag.key, + )?.value ?? false, + })), + handleLabPublicFeatureFlagUpdate, + error, + }; +}; diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 944a068eb..412de5b45 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -35,4 +35,5 @@ export enum SettingsPath { Releases = 'releases', AdminPanel = 'admin-panel', FeatureFlags = 'admin-panel/feature-flags', + Lab = 'lab', } diff --git a/packages/twenty-front/src/pages/settings/lab/SettingsLab.tsx b/packages/twenty-front/src/pages/settings/lab/SettingsLab.tsx new file mode 100644 index 000000000..6257f21d6 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/lab/SettingsLab.tsx @@ -0,0 +1,24 @@ +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsLabContent } from '@/settings/lab/components/SettingsLabContent'; +import { SettingsPath } from '@/types/SettingsPath'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; + +export const SettingsLab = () => { + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index abfb712d6..fdd091d7c 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -52,4 +52,5 @@ export const mockedClientConfig: ClientConfig = { }, api: { mutationMaximumAffectedRecords: 100 }, canManageFeatureFlags: true, + publicFeatureFlags: [], }; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts index 6ab51d89b..54db76fba 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts @@ -11,6 +11,10 @@ import { import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { + FeatureFlagException, + FeatureFlagExceptionCode, +} from 'src/engine/core-modules/feature-flag/feature-flag.exception'; import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate'; import { User } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; @@ -123,9 +127,9 @@ export class AdminPanelService { ) { featureFlagValidator.assertIsFeatureFlagKey( featureFlag, - new AuthException( + new FeatureFlagException( 'Invalid feature flag key', - AuthExceptionCode.INVALID_INPUT, + FeatureFlagExceptionCode.INVALID_FEATURE_FLAG_KEY, ), ); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 69527b001..dde6c9a69 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -1,9 +1,14 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { TrialPeriodDTO } from 'src/engine/core-modules/billing/dto/trial-period.dto'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output'; +registerEnumType(FeatureFlagKey, { + name: 'FeatureFlagKey', +}); + @ObjectType() class Billing { @Field(() => Boolean) @@ -52,6 +57,27 @@ class ApiConfig { mutationMaximumAffectedRecords: number; } +@ObjectType() +class PublicFeatureFlagMetadata { + @Field(() => String) + label: string; + + @Field(() => String) + description: string; + + @Field(() => String, { nullable: false, defaultValue: '' }) + imagePath: string; +} + +@ObjectType() +class PublicFeatureFlag { + @Field(() => FeatureFlagKey) + key: FeatureFlagKey; + + @Field(() => PublicFeatureFlagMetadata) + metadata: PublicFeatureFlagMetadata; +} + @ObjectType() export class ClientConfig { @Field(() => AuthProviders, { nullable: false }) @@ -98,4 +124,7 @@ export class ClientConfig { @Field(() => Boolean) canManageFeatureFlags: boolean; + + @Field(() => [PublicFeatureFlag]) + publicFeatureFlags: PublicFeatureFlag[]; } diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index f932b3d6a..04a25f286 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -2,6 +2,7 @@ import { Query, Resolver } from '@nestjs/graphql'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; import { ClientConfig } from './client-config.entity'; @@ -75,6 +76,7 @@ export class ClientConfigResolver { canManageFeatureFlags: this.environmentService.get('DEBUG_MODE') || this.environmentService.get('IS_BILLING_ENABLED'), + publicFeatureFlags: PUBLIC_FEATURE_FLAGS, }; return Promise.resolve(clientConfig); diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 7ba889915..a99b5cf75 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -22,6 +22,7 @@ import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-sto import { fileStorageModuleFactory } from 'src/engine/core-modules/file-storage/file-storage.module-factory'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { HealthModule } from 'src/engine/core-modules/health/health.module'; +import { LabModule } from 'src/engine/core-modules/lab/lab.module'; import { LLMChatModelModule } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module'; import { llmChatModelModuleFactory } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module-factory'; import { LLMTracingModule } from 'src/engine/core-modules/llm-tracing/llm-tracing.module'; @@ -72,6 +73,7 @@ import { FileModule } from './file/file.module'; ActorModule, TelemetryModule, AdminPanelModule, + LabModule, EnvironmentModule.forRoot({}), RedisClientModule, FileStorageModule.forRootAsync({ diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts new file mode 100644 index 000000000..bd57b9f60 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts @@ -0,0 +1,14 @@ +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; + +type FeatureFlagMetadata = { + label: string; + description: string; + imagePath: string; +}; + +export type PublicFeatureFlag = { + key: Extract; + metadata: FeatureFlagMetadata; +}; + +export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = []; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.exception.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.exception.ts new file mode 100644 index 000000000..050852840 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.exception.ts @@ -0,0 +1,14 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class FeatureFlagException extends CustomException { + code: FeatureFlagExceptionCode; + constructor(message: string, code: FeatureFlagExceptionCode) { + super(message, code); + } +} + +export enum FeatureFlagExceptionCode { + INVALID_FEATURE_FLAG_KEY = 'INVALID_FEATURE_FLAG_KEY', + FEATURE_FLAG_IS_NOT_PUBLIC = 'FEATURE_FLAG_IS_NOT_PUBLIC', + FEATURE_FLAG_NOT_FOUND = 'FEATURE_FLAG_NOT_FOUND', +} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.ts index 40245a9fc..9022ccea3 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/feature-flag.validate.ts @@ -1,5 +1,5 @@ -import { CustomException } from 'src/utils/custom-exception'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { CustomException } from 'src/utils/custom-exception'; import { isDefined } from 'src/utils/is-defined'; const assertIsFeatureFlagKey = ( diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate.spec.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate.spec.ts new file mode 100644 index 000000000..2998992ae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate.spec.ts @@ -0,0 +1,109 @@ +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { PublicFeatureFlag } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate'; + +jest.mock( + 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum', + () => ({ + FeatureFlagKey: { + mockKey1: 'MOCK_KEY_1', + mockKey2: 'MOCK_KEY_2', + }, + }), +); + +const mockPublicFeatureFlag = { + key: 'MOCK_KEY_1', + metadata: { + label: 'Mock Label 1', + description: 'Mock Description 1', + imagePath: 'mock/path/1', + }, +}; + +jest.mock( + 'src/engine/core-modules/lab/utils/is-public-feature-flag.util', + () => ({ + isPublicFeatureFlag: ( + key: FeatureFlagKey, + ): key is PublicFeatureFlag['key'] => { + if (!key) return false; + + return key === mockPublicFeatureFlag.key; + }, + }), +); + +// Note: We're using a single public flag for testing as it's sufficient to verify +// the validator's behavior. The validator's role is to check if a flag exists in +// the PUBLIC_FEATURE_FLAGS array, so testing with one flag adequately covers this +// functionality. Adding more flags wouldn't increase the test coverage meaningfully. + +describe('publicFeatureFlagValidator', () => { + describe('assertIsPublicFeatureFlag', () => { + const mockException = new AuthException( + 'Not a public feature flag', + AuthExceptionCode.INVALID_INPUT, + ); + + it('should not throw for public feature flags', () => { + const publicFlag = mockPublicFeatureFlag.key as FeatureFlagKey; + + expect(() => { + publicFeatureFlagValidator.assertIsPublicFeatureFlag( + publicFlag, + mockException, + ); + }).not.toThrow(); + }); + + it('should throw the provided exception for non-public feature flags', () => { + const nonPublicFlag = 'MOCK_KEY_2' as FeatureFlagKey; + + expect(() => { + publicFeatureFlagValidator.assertIsPublicFeatureFlag( + nonPublicFlag, + mockException, + ); + }).toThrow(mockException); + }); + + it('should throw the provided exception for undefined key', () => { + expect(() => { + publicFeatureFlagValidator.assertIsPublicFeatureFlag( + undefined as unknown as FeatureFlagKey, + mockException, + ); + }).toThrow(mockException); + }); + + it('should throw the provided exception for null key', () => { + expect(() => { + publicFeatureFlagValidator.assertIsPublicFeatureFlag( + null as unknown as FeatureFlagKey, + mockException, + ); + }).toThrow(mockException); + }); + + it('should maintain type assertion after validation', () => { + const publicFlag = mockPublicFeatureFlag; + + const testTypeAssertion = (flag: FeatureFlagKey) => { + publicFeatureFlagValidator.assertIsPublicFeatureFlag( + flag, + mockException, + ); + const _test: PublicFeatureFlag['key'] = flag; + + return true; + }; + + expect(testTypeAssertion(publicFlag.key as FeatureFlagKey)).toBe(true); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate.ts new file mode 100644 index 000000000..d28e9359c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate.ts @@ -0,0 +1,19 @@ +import { PublicFeatureFlag } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { isPublicFeatureFlag } from 'src/engine/core-modules/lab/utils/is-public-feature-flag.util'; +import { CustomException } from 'src/utils/custom-exception'; + +const assertIsPublicFeatureFlag = ( + key: FeatureFlagKey, + exceptionToThrow: CustomException, +): asserts key is PublicFeatureFlag['key'] => { + if (!isPublicFeatureFlag(key)) { + throw exceptionToThrow; + } +}; + +export const publicFeatureFlagValidator: { + assertIsPublicFeatureFlag: typeof assertIsPublicFeatureFlag; +} = { + assertIsPublicFeatureFlag: assertIsPublicFeatureFlag, +}; diff --git a/packages/twenty-server/src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input.ts b/packages/twenty-server/src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input.ts new file mode 100644 index 000000000..3eea0b620 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input.ts @@ -0,0 +1,16 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsBoolean, IsNotEmpty } from 'class-validator'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; + +@InputType() +export class UpdateLabPublicFeatureFlagInput { + @Field(() => String) + @IsNotEmpty() + publicFeatureFlag: FeatureFlagKey; + + @Field(() => Boolean) + @IsBoolean() + value: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts b/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts new file mode 100644 index 000000000..87f0cd031 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { LabResolver } from './lab.resolver'; + +import { LabService } from './services/lab.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([FeatureFlagEntity, Workspace], 'core')], + providers: [LabService, LabResolver], + exports: [LabService], +}) +export class LabModule {} diff --git a/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts b/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts new file mode 100644 index 000000000..8e20917c8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts @@ -0,0 +1,26 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input'; +import { LabService } from 'src/engine/core-modules/lab/services/lab.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Resolver() +@UseFilters(AuthGraphqlApiExceptionFilter) +export class LabResolver { + constructor(private labService: LabService) {} + + @UseGuards(WorkspaceAuthGuard) + @Mutation(() => Boolean) + async updateLabPublicFeatureFlag( + @Args('input') input: UpdateLabPublicFeatureFlagInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + await this.labService.updateLabPublicFeatureFlag(workspace.id, input); + + return true; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/lab/services/lab.service.ts b/packages/twenty-server/src/engine/core-modules/lab/services/lab.service.ts new file mode 100644 index 000000000..949c2cb07 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/lab/services/lab.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { + FeatureFlagException, + FeatureFlagExceptionCode, +} from 'src/engine/core-modules/feature-flag/feature-flag.exception'; +import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate'; +import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate'; +import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; + +@Injectable() +export class LabService { + constructor( + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + ) {} + + async updateLabPublicFeatureFlag( + workspaceId: string, + payload: UpdateLabPublicFeatureFlagInput, + ): Promise { + featureFlagValidator.assertIsFeatureFlagKey( + payload.publicFeatureFlag, + new FeatureFlagException( + 'Invalid feature flag key', + FeatureFlagExceptionCode.INVALID_FEATURE_FLAG_KEY, + ), + ); + + publicFeatureFlagValidator.assertIsPublicFeatureFlag( + FeatureFlagKey[payload.publicFeatureFlag], + new FeatureFlagException( + 'Feature flag is not public', + FeatureFlagExceptionCode.FEATURE_FLAG_IS_NOT_PUBLIC, + ), + ); + + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + relations: ['featureFlags'], + }); + + workspaceValidator.assertIsDefinedOrThrow( + workspace, + new AuthException('Workspace not found', AuthExceptionCode.INVALID_INPUT), + ); + + const existingFlag = workspace.featureFlags?.find( + (flag) => flag.key === FeatureFlagKey[payload.publicFeatureFlag], + ); + + if (!existingFlag) { + throw new FeatureFlagException( + 'Public feature flag not found', + FeatureFlagExceptionCode.FEATURE_FLAG_NOT_FOUND, + ); + } + + await this.featureFlagRepository.update(existingFlag.id, { + value: payload.value, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/lab/utils/is-public-feature-flag.util.spec.ts b/packages/twenty-server/src/engine/core-modules/lab/utils/is-public-feature-flag.util.spec.ts new file mode 100644 index 000000000..5aa2a9791 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/lab/utils/is-public-feature-flag.util.spec.ts @@ -0,0 +1,48 @@ +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; + +import { isPublicFeatureFlag } from './is-public-feature-flag.util'; + +jest.mock( + 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum', + () => ({ + FeatureFlagKey: { + mockKey1: 'MOCK_KEY_1', + mockKey2: 'MOCK_KEY_2', + }, + }), +); + +jest.mock( + 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const', + () => ({ + PUBLIC_FEATURE_FLAGS: [ + { + key: 'MOCK_KEY_1', + metadata: { + label: 'Mock Label 1', + description: 'Mock Description 1', + imagePath: 'mock/path/1', + }, + }, + ], + }), +); + +describe('isPublicFeatureFlag', () => { + it('should return true for public flags', () => { + const publicFlag = 'MOCK_KEY_1'; + + expect(isPublicFeatureFlag(publicFlag as FeatureFlagKey)).toBe(true); + }); + + it('should return false for non-public flags', () => { + const nonPublicFlag = 'MOCK_KEY_2'; + + expect(isPublicFeatureFlag(nonPublicFlag as FeatureFlagKey)).toBe(false); + }); + + it('should return false for undefined/null', () => { + expect(isPublicFeatureFlag(undefined as any)).toBe(false); + expect(isPublicFeatureFlag(null as any)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/lab/utils/is-public-feature-flag.util.ts b/packages/twenty-server/src/engine/core-modules/lab/utils/is-public-feature-flag.util.ts new file mode 100644 index 000000000..bd08582ba --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/lab/utils/is-public-feature-flag.util.ts @@ -0,0 +1,15 @@ +import { + PUBLIC_FEATURE_FLAGS, + PublicFeatureFlag, +} from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; + +export const isPublicFeatureFlag = ( + key: FeatureFlagKey, +): key is PublicFeatureFlag['key'] => { + if (!key) { + return false; + } + + return PUBLIC_FEATURE_FLAGS.some((flag) => flag.key === key); +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts index ed63b20ee..a672e70f5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-gate.decorator.ts @@ -1,3 +1,5 @@ +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { isPublicFeatureFlag } from 'src/engine/core-modules/lab/utils/is-public-feature-flag.util'; import { TypedReflect } from 'src/utils/typed-reflect'; export interface WorkspaceGateOptions { @@ -5,6 +7,15 @@ export interface WorkspaceGateOptions { } export function WorkspaceGate(options: WorkspaceGateOptions) { + const flagKey = options.featureFlag as FeatureFlagKey; + + if (isPublicFeatureFlag(flagKey)) { + throw new Error( + `Public feature flag "${flagKey}" cannot be used to gate entities. ` + + 'Public flags should not be used for entity gating as they can be toggled by users.', + ); + } + return (target: any, propertyKey?: string | symbol) => { if (propertyKey !== undefined) { TypedReflect.defineMetadata( diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 3474e26c4..13561f90f 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -131,6 +131,7 @@ export { IconFilterCog, IconFilterOff, IconFlag, + IconFlask, IconFocusCentered, IconFolder, IconFolderOpen, diff --git a/packages/twenty-website/public/images/lab/is-workflow-enabled.png b/packages/twenty-website/public/images/lab/is-workflow-enabled.png new file mode 100644 index 000000000..2641aef33 Binary files /dev/null and b/packages/twenty-website/public/images/lab/is-workflow-enabled.png differ