Twenty config admin panel integration (#11755)

closes https://github.com/twentyhq/core-team-issues/issues/761
closes https://github.com/twentyhq/core-team-issues/issues/762

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
nitin
2025-04-30 12:42:59 +05:30
committed by GitHub
parent 842367f7bb
commit e957b1acd6
73 changed files with 2958 additions and 853 deletions

View File

@ -33,6 +33,8 @@ jobs:
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
# Remove line below when true becomes the default value (soon)
echo "CONFIG_VARIABLES_IN_DB_ENABLED=true" >> packages/twenty-docker/.env
echo "Docker compose build..." echo "Docker compose build..."
cd packages/twenty-docker/ cd packages/twenty-docker/

View File

@ -301,6 +301,7 @@ export type ClientConfig = {
defaultSubdomain?: Maybe<Scalars['String']>; defaultSubdomain?: Maybe<Scalars['String']>;
frontDomain: Scalars['String']; frontDomain: Scalars['String'];
isAttachmentPreviewEnabled: Scalars['Boolean']; isAttachmentPreviewEnabled: Scalars['Boolean'];
isConfigVariablesInDbEnabled: Scalars['Boolean'];
isEmailVerificationRequired: Scalars['Boolean']; isEmailVerificationRequired: Scalars['Boolean'];
isGoogleCalendarEnabled: Scalars['Boolean']; isGoogleCalendarEnabled: Scalars['Boolean'];
isGoogleMessagingEnabled: Scalars['Boolean']; isGoogleMessagingEnabled: Scalars['Boolean'];
@ -318,14 +319,32 @@ export type ComputeStepOutputSchemaInput = {
step: Scalars['JSON']; step: Scalars['JSON'];
}; };
export enum ConfigSource {
DATABASE = 'DATABASE',
DEFAULT = 'DEFAULT',
ENVIRONMENT = 'ENVIRONMENT'
}
export type ConfigVariable = { export type ConfigVariable = {
__typename?: 'ConfigVariable'; __typename?: 'ConfigVariable';
description: Scalars['String']; description: Scalars['String'];
isEnvOnly: Scalars['Boolean'];
isSensitive: Scalars['Boolean']; isSensitive: Scalars['Boolean'];
name: Scalars['String']; name: Scalars['String'];
value: Scalars['String']; options?: Maybe<Scalars['JSON']>;
source: ConfigSource;
type: ConfigVariableType;
value?: Maybe<Scalars['JSON']>;
}; };
export enum ConfigVariableType {
ARRAY = 'ARRAY',
BOOLEAN = 'BOOLEAN',
ENUM = 'ENUM',
NUMBER = 'NUMBER',
STRING = 'STRING'
}
export enum ConfigVariablesGroup { export enum ConfigVariablesGroup {
AnalyticsConfig = 'AnalyticsConfig', AnalyticsConfig = 'AnalyticsConfig',
BillingConfig = 'BillingConfig', BillingConfig = 'BillingConfig',
@ -868,6 +887,7 @@ export type Mutation = {
checkoutSession: BillingSessionOutput; checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON']; computeStepOutputSchema: Scalars['JSON'];
createApprovedAccessDomain: ApprovedAccessDomain; createApprovedAccessDomain: ApprovedAccessDomain;
createDatabaseConfigVariable: Scalars['Boolean'];
createDraftFromWorkflowVersion: WorkflowVersion; createDraftFromWorkflowVersion: WorkflowVersion;
createOIDCIdentityProvider: SetupSsoOutput; createOIDCIdentityProvider: SetupSsoOutput;
createOneAppToken: AppToken; createOneAppToken: AppToken;
@ -880,6 +900,7 @@ export type Mutation = {
deactivateWorkflowVersion: Scalars['Boolean']; deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace; deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteOneField: Field; deleteOneField: Field;
deleteOneObject: Object; deleteOneObject: Object;
deleteOneRole: Scalars['String']; deleteOneRole: Scalars['String'];
@ -914,6 +935,7 @@ export type Mutation = {
switchToYearlyInterval: BillingUpdateOutput; switchToYearlyInterval: BillingUpdateOutput;
track: Analytics; track: Analytics;
trackAnalytics: Analytics; trackAnalytics: Analytics;
updateDatabaseConfigVariable: Scalars['Boolean'];
updateLabPublicFeatureFlag: FeatureFlagDto; updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneField: Field; updateOneField: Field;
updateOneObject: Object; updateOneObject: Object;
@ -971,6 +993,12 @@ export type MutationCreateApprovedAccessDomainArgs = {
}; };
export type MutationCreateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
};
export type MutationCreateDraftFromWorkflowVersionArgs = { export type MutationCreateDraftFromWorkflowVersionArgs = {
input: CreateDraftFromWorkflowVersionInput; input: CreateDraftFromWorkflowVersionInput;
}; };
@ -1016,6 +1044,11 @@ export type MutationDeleteApprovedAccessDomainArgs = {
}; };
export type MutationDeleteDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
export type MutationDeleteOneFieldArgs = { export type MutationDeleteOneFieldArgs = {
input: DeleteOneFieldInput; input: DeleteOneFieldInput;
}; };
@ -1162,6 +1195,12 @@ export type MutationTrackAnalyticsArgs = {
}; };
export type MutationUpdateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
};
export type MutationUpdateLabPublicFeatureFlagArgs = { export type MutationUpdateLabPublicFeatureFlagArgs = {
input: UpdateLabPublicFeatureFlagInput; input: UpdateLabPublicFeatureFlagInput;
}; };
@ -1485,6 +1524,7 @@ export type Query = {
getApprovedAccessDomains: Array<ApprovedAccessDomain>; getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON']; getAvailablePackages: Scalars['JSON'];
getConfigVariablesGrouped: ConfigVariablesOutput; getConfigVariablesGrouped: ConfigVariablesOutput;
getDatabaseConfigVariable: ConfigVariable;
getIndicatorHealthStatus: AdminPanelHealthServiceData; getIndicatorHealthStatus: AdminPanelHealthServiceData;
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>; getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
getPostgresCredentials?: Maybe<PostgresCredentials>; getPostgresCredentials?: Maybe<PostgresCredentials>;
@ -1540,6 +1580,11 @@ export type QueryGetAvailablePackagesArgs = {
}; };
export type QueryGetDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
export type QueryGetIndicatorHealthStatusArgs = { export type QueryGetIndicatorHealthStatusArgs = {
indicatorId: HealthIndicatorId; indicatorId: HealthIndicatorId;
}; };
@ -2665,7 +2710,7 @@ export type SwitchSubscriptionToYearlyIntervalMutation = { __typename?: 'Mutatio
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; 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, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', 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 GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, isConfigVariablesInDbEnabled: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', 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 SearchQueryVariables = Exact<{ export type SearchQueryVariables = Exact<{
searchInput: Scalars['String']; searchInput: Scalars['String'];
@ -2683,6 +2728,41 @@ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]
export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } }; export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } };
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
value: Scalars['JSON'];
}>;
export type CreateDatabaseConfigVariableMutation = { __typename?: 'Mutation', createDatabaseConfigVariable: boolean };
export type DeleteDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
}>;
export type DeleteDatabaseConfigVariableMutation = { __typename?: 'Mutation', deleteDatabaseConfigVariable: boolean };
export type UpdateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
value: Scalars['JSON'];
}>;
export type UpdateDatabaseConfigVariableMutation = { __typename?: 'Mutation', updateDatabaseConfigVariable: boolean };
export type GetConfigVariablesGroupedQueryVariables = Exact<{ [key: string]: never; }>;
export type GetConfigVariablesGroupedQuery = { __typename?: 'Query', getConfigVariablesGrouped: { __typename?: 'ConfigVariablesOutput', groups: Array<{ __typename?: 'ConfigVariablesGroupData', name: ConfigVariablesGroup, description: string, isHiddenOnLoad: boolean, variables: Array<{ __typename?: 'ConfigVariable', name: string, description: string, value?: any | null, isSensitive: boolean, isEnvOnly: boolean, type: ConfigVariableType, options?: any | null, source: ConfigSource }> }> } };
export type GetDatabaseConfigVariableQueryVariables = Exact<{
key: Scalars['String'];
}>;
export type GetDatabaseConfigVariableQuery = { __typename?: 'Query', getDatabaseConfigVariable: { __typename?: 'ConfigVariable', name: string, description: string, value?: any | null, isSensitive: boolean, isEnvOnly: boolean, type: ConfigVariableType, options?: any | null, source: ConfigSource } };
export type UpdateWorkspaceFeatureFlagMutationVariables = Exact<{ export type UpdateWorkspaceFeatureFlagMutationVariables = Exact<{
workspaceId: Scalars['String']; workspaceId: Scalars['String'];
featureFlag: Scalars['String']; featureFlag: Scalars['String'];
@ -2699,11 +2779,6 @@ 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 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 GetConfigVariablesGroupedQueryVariables = Exact<{ [key: string]: never; }>;
export type GetConfigVariablesGroupedQuery = { __typename?: 'Query', getConfigVariablesGrouped: { __typename?: 'ConfigVariablesOutput', groups: Array<{ __typename?: 'ConfigVariablesGroupData', name: ConfigVariablesGroup, description: string, isHiddenOnLoad: boolean, variables: Array<{ __typename?: 'ConfigVariable', name: string, description: string, value: string, isSensitive: boolean }> }> } };
export type GetVersionInfoQueryVariables = Exact<{ [key: string]: never; }>; export type GetVersionInfoQueryVariables = Exact<{ [key: string]: never; }>;
@ -4510,6 +4585,7 @@ export const GetClientConfigDocument = gql`
isMicrosoftCalendarEnabled isMicrosoftCalendarEnabled
isGoogleMessagingEnabled isGoogleMessagingEnabled
isGoogleCalendarEnabled isGoogleCalendarEnabled
isConfigVariablesInDbEnabled
} }
} }
`; `;
@ -4622,6 +4698,191 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>; export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>; export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>; export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions<SkipSyncEmailOnboardingStepMutation, SkipSyncEmailOnboardingStepMutationVariables>;
export const CreateDatabaseConfigVariableDocument = gql`
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
createDatabaseConfigVariable(key: $key, value: $value)
}
`;
export type CreateDatabaseConfigVariableMutationFn = Apollo.MutationFunction<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
/**
* __useCreateDatabaseConfigVariableMutation__
*
* To run a mutation, you first call `useCreateDatabaseConfigVariableMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateDatabaseConfigVariableMutation` 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 [createDatabaseConfigVariableMutation, { data, loading, error }] = useCreateDatabaseConfigVariableMutation({
* variables: {
* key: // value for 'key'
* value: // value for 'value'
* },
* });
*/
export function useCreateDatabaseConfigVariableMutation(baseOptions?: Apollo.MutationHookOptions<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>(CreateDatabaseConfigVariableDocument, options);
}
export type CreateDatabaseConfigVariableMutationHookResult = ReturnType<typeof useCreateDatabaseConfigVariableMutation>;
export type CreateDatabaseConfigVariableMutationResult = Apollo.MutationResult<CreateDatabaseConfigVariableMutation>;
export type CreateDatabaseConfigVariableMutationOptions = Apollo.BaseMutationOptions<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
export const DeleteDatabaseConfigVariableDocument = gql`
mutation DeleteDatabaseConfigVariable($key: String!) {
deleteDatabaseConfigVariable(key: $key)
}
`;
export type DeleteDatabaseConfigVariableMutationFn = Apollo.MutationFunction<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
/**
* __useDeleteDatabaseConfigVariableMutation__
*
* To run a mutation, you first call `useDeleteDatabaseConfigVariableMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteDatabaseConfigVariableMutation` 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 [deleteDatabaseConfigVariableMutation, { data, loading, error }] = useDeleteDatabaseConfigVariableMutation({
* variables: {
* key: // value for 'key'
* },
* });
*/
export function useDeleteDatabaseConfigVariableMutation(baseOptions?: Apollo.MutationHookOptions<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>(DeleteDatabaseConfigVariableDocument, options);
}
export type DeleteDatabaseConfigVariableMutationHookResult = ReturnType<typeof useDeleteDatabaseConfigVariableMutation>;
export type DeleteDatabaseConfigVariableMutationResult = Apollo.MutationResult<DeleteDatabaseConfigVariableMutation>;
export type DeleteDatabaseConfigVariableMutationOptions = Apollo.BaseMutationOptions<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
export const UpdateDatabaseConfigVariableDocument = gql`
mutation UpdateDatabaseConfigVariable($key: String!, $value: JSON!) {
updateDatabaseConfigVariable(key: $key, value: $value)
}
`;
export type UpdateDatabaseConfigVariableMutationFn = Apollo.MutationFunction<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>;
/**
* __useUpdateDatabaseConfigVariableMutation__
*
* To run a mutation, you first call `useUpdateDatabaseConfigVariableMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateDatabaseConfigVariableMutation` 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 [updateDatabaseConfigVariableMutation, { data, loading, error }] = useUpdateDatabaseConfigVariableMutation({
* variables: {
* key: // value for 'key'
* value: // value for 'value'
* },
* });
*/
export function useUpdateDatabaseConfigVariableMutation(baseOptions?: Apollo.MutationHookOptions<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>(UpdateDatabaseConfigVariableDocument, options);
}
export type UpdateDatabaseConfigVariableMutationHookResult = ReturnType<typeof useUpdateDatabaseConfigVariableMutation>;
export type UpdateDatabaseConfigVariableMutationResult = Apollo.MutationResult<UpdateDatabaseConfigVariableMutation>;
export type UpdateDatabaseConfigVariableMutationOptions = Apollo.BaseMutationOptions<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>;
export const GetConfigVariablesGroupedDocument = gql`
query GetConfigVariablesGrouped {
getConfigVariablesGrouped {
groups {
name
description
isHiddenOnLoad
variables {
name
description
value
isSensitive
isEnvOnly
type
options
source
}
}
}
}
`;
/**
* __useGetConfigVariablesGroupedQuery__
*
* To run a query within a React component, call `useGetConfigVariablesGroupedQuery` and pass it any options that fit your needs.
* When your component renders, `useGetConfigVariablesGroupedQuery` 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 } = useGetConfigVariablesGroupedQuery({
* variables: {
* },
* });
*/
export function useGetConfigVariablesGroupedQuery(baseOptions?: Apollo.QueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export function useGetConfigVariablesGroupedLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export type GetConfigVariablesGroupedQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedQuery>;
export type GetConfigVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedLazyQuery>;
export type GetConfigVariablesGroupedQueryResult = Apollo.QueryResult<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>;
export const GetDatabaseConfigVariableDocument = gql`
query GetDatabaseConfigVariable($key: String!) {
getDatabaseConfigVariable(key: $key) {
name
description
value
isSensitive
isEnvOnly
type
options
source
}
}
`;
/**
* __useGetDatabaseConfigVariableQuery__
*
* To run a query within a React component, call `useGetDatabaseConfigVariableQuery` and pass it any options that fit your needs.
* When your component renders, `useGetDatabaseConfigVariableQuery` 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 } = useGetDatabaseConfigVariableQuery({
* variables: {
* key: // value for 'key'
* },
* });
*/
export function useGetDatabaseConfigVariableQuery(baseOptions: Apollo.QueryHookOptions<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>(GetDatabaseConfigVariableDocument, options);
}
export function useGetDatabaseConfigVariableLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>(GetDatabaseConfigVariableDocument, options);
}
export type GetDatabaseConfigVariableQueryHookResult = ReturnType<typeof useGetDatabaseConfigVariableQuery>;
export type GetDatabaseConfigVariableLazyQueryHookResult = ReturnType<typeof useGetDatabaseConfigVariableLazyQuery>;
export type GetDatabaseConfigVariableQueryResult = Apollo.QueryResult<GetDatabaseConfigVariableQuery, GetDatabaseConfigVariableQueryVariables>;
export const UpdateWorkspaceFeatureFlagDocument = gql` export const UpdateWorkspaceFeatureFlagDocument = gql`
mutation UpdateWorkspaceFeatureFlag($workspaceId: String!, $featureFlag: String!, $value: Boolean!) { mutation UpdateWorkspaceFeatureFlag($workspaceId: String!, $featureFlag: String!, $value: Boolean!) {
updateWorkspaceFeatureFlag( updateWorkspaceFeatureFlag(
@ -4714,50 +4975,6 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>; export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>; export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>; export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions<UserLookupAdminPanelMutation, UserLookupAdminPanelMutationVariables>;
export const GetConfigVariablesGroupedDocument = gql`
query GetConfigVariablesGrouped {
getConfigVariablesGrouped {
groups {
name
description
isHiddenOnLoad
variables {
name
description
value
isSensitive
}
}
}
}
`;
/**
* __useGetConfigVariablesGroupedQuery__
*
* To run a query within a React component, call `useGetConfigVariablesGroupedQuery` and pass it any options that fit your needs.
* When your component renders, `useGetConfigVariablesGroupedQuery` 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 } = useGetConfigVariablesGroupedQuery({
* variables: {
* },
* });
*/
export function useGetConfigVariablesGroupedQuery(baseOptions?: Apollo.QueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export function useGetConfigVariablesGroupedLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>(GetConfigVariablesGroupedDocument, options);
}
export type GetConfigVariablesGroupedQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedQuery>;
export type GetConfigVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetConfigVariablesGroupedLazyQuery>;
export type GetConfigVariablesGroupedQueryResult = Apollo.QueryResult<GetConfigVariablesGroupedQuery, GetConfigVariablesGroupedQueryVariables>;
export const GetVersionInfoDocument = gql` export const GetVersionInfoDocument = gql`
query GetVersionInfo { query GetVersionInfo {
versionInfo { versionInfo {

View File

@ -281,11 +281,11 @@ const SettingsAdminIndicatorHealthStatus = lazy(() =>
})), })),
); );
const SettingsAdminSecondaryEnvVariables = lazy(() => const SettingsAdminConfigVariableDetails = lazy(() =>
import( import(
'~/pages/settings/admin-panel/SettingsAdminSecondaryEnvVariables' '~/pages/settings/admin-panel/SettingsAdminConfigVariableDetails'
).then((module) => ({ ).then((module) => ({
default: module.SettingsAdminSecondaryEnvVariables, default: module.SettingsAdminConfigVariableDetails,
})), })),
); );
@ -505,9 +505,10 @@ export const SettingsRoutes = ({
path={SettingsPath.AdminPanelIndicatorHealthStatus} path={SettingsPath.AdminPanelIndicatorHealthStatus}
element={<SettingsAdminIndicatorHealthStatus />} element={<SettingsAdminIndicatorHealthStatus />}
/> />
<Route <Route
path={SettingsPath.AdminPanelOtherEnvVariables} path={SettingsPath.AdminPanelConfigVariableDetails}
element={<SettingsAdminSecondaryEnvVariables />} element={<SettingsAdminConfigVariableDetails />}
/> />
</> </>
)} )}

View File

@ -7,6 +7,7 @@ import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionId
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState'; import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState'; import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState'; import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
@ -21,8 +22,8 @@ import { supportChatState } from '@/client-config/states/supportChatState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilState, useSetRecoilState } from 'recoil';
import { useGetClientConfigQuery } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { useGetClientConfigQuery } from '~/generated/graphql';
export const ClientConfigProviderEffect = () => { export const ClientConfigProviderEffect = () => {
const setIsDebugMode = useSetRecoilState(isDebugModeState); const setIsDebugMode = useSetRecoilState(isDebugModeState);
@ -82,6 +83,10 @@ export const ClientConfigProviderEffect = () => {
isAttachmentPreviewEnabledState, isAttachmentPreviewEnabledState,
); );
const setIsConfigVariablesInDbEnabled = useSetRecoilState(
isConfigVariablesInDbEnabledState,
);
const { data, loading, error } = useGetClientConfigQuery({ const { data, loading, error } = useGetClientConfigQuery({
skip: clientConfigApiStatus.isLoaded, skip: clientConfigApiStatus.isLoaded,
}); });
@ -157,6 +162,9 @@ export const ClientConfigProviderEffect = () => {
setIsAttachmentPreviewEnabled( setIsAttachmentPreviewEnabled(
data?.clientConfig?.isAttachmentPreviewEnabled, data?.clientConfig?.isAttachmentPreviewEnabled,
); );
setIsConfigVariablesInDbEnabled(
data?.clientConfig?.isConfigVariablesInDbEnabled,
);
}, [ }, [
data, data,
setIsDebugMode, setIsDebugMode,
@ -182,6 +190,7 @@ export const ClientConfigProviderEffect = () => {
setGoogleMessagingEnabled, setGoogleMessagingEnabled,
setGoogleCalendarEnabled, setGoogleCalendarEnabled,
setIsAttachmentPreviewEnabled, setIsAttachmentPreviewEnabled,
setIsConfigVariablesInDbEnabled,
]); ]);
return <></>; return <></>;

View File

@ -61,6 +61,7 @@ export const GET_CLIENT_CONFIG = gql`
isMicrosoftCalendarEnabled isMicrosoftCalendarEnabled
isGoogleMessagingEnabled isGoogleMessagingEnabled
isGoogleCalendarEnabled isGoogleCalendarEnabled
isConfigVariablesInDbEnabled
} }
} }
`; `;

View File

@ -0,0 +1,5 @@
import { createState } from 'twenty-ui/utilities';
export const isConfigVariablesInDbEnabledState = createState<boolean>({
key: 'isConfigVariablesInDbEnabled',
defaultValue: false,
});

View File

@ -20,8 +20,8 @@ export const SettingsAdminContent = () => {
disabled: !canAccessFullAdminPanel && !canImpersonate, disabled: !canAccessFullAdminPanel && !canImpersonate,
}, },
{ {
id: SETTINGS_ADMIN_TABS.ENV_VARIABLES, id: SETTINGS_ADMIN_TABS.CONFIG_VARIABLES,
title: t`Env Variables`, title: t`Config Variables`,
Icon: IconVariable, Icon: IconVariable,
disabled: !canAccessFullAdminPanel, disabled: !canAccessFullAdminPanel,
}, },

View File

@ -1,68 +0,0 @@
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsListItemCardContent } from '@/settings/components/SettingsListItemCardContent';
import { SettingsPath } from '@/types/SettingsPath';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, IconHeartRateMonitor } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { useGetConfigVariablesGroupedQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledGroupContainer = styled.div``;
const StyledInfoText = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
`;
const StyledCard = styled(Card)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
export const SettingsAdminEnvVariables = () => {
const theme = useTheme();
const { data: configVariables, loading: configVariablesLoading } =
useGetConfigVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const visibleGroups =
configVariables?.getConfigVariablesGrouped.groups.filter(
(group) => !group.isHiddenOnLoad,
) ?? [];
if (configVariablesLoading) {
return <SettingsAdminTabSkeletonLoader />;
}
return (
<>
<Section>
<StyledInfoText>
{t`These are only the server values. Ensure your worker environment has the same variables and values, this is required for asynchronous tasks like email sync.`}
</StyledInfoText>
</Section>
{visibleGroups.map((group) => (
<StyledGroupContainer key={group.name}>
<H2Title title={group.name} description={group.description} />
{group.variables.length > 0 && (
<SettingsAdminEnvVariablesTable variables={group.variables} />
)}
</StyledGroupContainer>
))}
<Section>
<StyledCard rounded>
<SettingsListItemCardContent
label={t`Other Variables`}
to={getSettingsPath(SettingsPath.AdminPanelOtherEnvVariables)}
rightComponent={null}
LeftIcon={IconHeartRateMonitor}
LeftIconColor={theme.font.color.tertiary}
/>
</StyledCard>
</Section>
</>
);
};

View File

@ -1,170 +0,0 @@
import { SettingsAdminEnvCopyableText } from '@/settings/admin-panel/components/SettingsAdminEnvCopyableText';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { IconChevronRight, IconEye, IconEyeOff } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
type SettingsAdminEnvVariablesRowProps = {
variable: {
name: string;
description: string;
value: string;
isSensitive: boolean;
};
isExpanded: boolean;
onExpandToggle: (name: string) => void;
};
const StyledTruncatedCell = styled(TableCell)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
`;
const StyledButton = styled(motion.button)`
align-items: center;
border: none;
display: flex;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(1)};
background-color: transparent;
height: 24px;
width: 24px;
box-sizing: border-box;
cursor: pointer;
`;
const MotionIconChevronDown = motion(IconChevronRight);
const StyledEllipsisLabel = styled.div`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
const StyledValueContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: space-between;
width: 100%;
`;
const StyledTableRow = styled(TableRow)<{ isExpanded: boolean }>`
background-color: ${({ isExpanded, theme }) =>
isExpanded ? theme.background.transparent.light : 'transparent'};
`;
const StyledExpandableContainer = styled.div`
width: 100%;
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminEnvVariablesRow = ({
variable,
isExpanded,
onExpandToggle,
}: SettingsAdminEnvVariablesRowProps) => {
const [showSensitiveValue, setShowSensitiveValue] = useState(false);
const theme = useTheme();
const displayValue =
variable.value === ''
? 'null'
: variable.isSensitive && !showSensitiveValue
? '••••••'
: variable.value;
const handleToggleVisibility = (event: React.MouseEvent) => {
event.stopPropagation();
setShowSensitiveValue(!showSensitiveValue);
};
const environmentVariablesDetails = [
{
label: 'Name',
value: <SettingsAdminEnvCopyableText text={variable.name} />,
},
{
label: 'Description',
value: (
<SettingsAdminEnvCopyableText
text={variable.description}
maxRows={1}
multiline={true}
/>
),
},
{
label: 'Value',
value: (
<StyledValueContainer>
<SettingsAdminEnvCopyableText
text={variable.value}
displayText={displayValue}
multiline={true}
/>
{variable.isSensitive && variable.value !== '' && (
<LightIconButton
Icon={showSensitiveValue ? IconEyeOff : IconEye}
size="small"
accent="secondary"
onClick={handleToggleVisibility}
/>
)}
</StyledValueContainer>
),
},
];
return (
<>
<StyledTableRow
onClick={() => onExpandToggle(variable.name)}
gridAutoColumns="5fr 4fr 3fr 1fr"
isExpanded={isExpanded}
>
<StyledTruncatedCell color={theme.font.color.primary}>
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
</StyledTruncatedCell>
<StyledTruncatedCell>
<StyledEllipsisLabel>{variable.description}</StyledEllipsisLabel>
</StyledTruncatedCell>
<StyledTruncatedCell align="right">
<StyledEllipsisLabel>{displayValue}</StyledEllipsisLabel>
</StyledTruncatedCell>
<TableCell align="right">
<StyledButton
onClick={(e) => {
e.stopPropagation();
onExpandToggle(variable.name);
}}
>
<MotionIconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
initial={false}
animate={{ rotate: isExpanded ? 90 : 0 }}
/>
</StyledButton>
</TableCell>
</StyledTableRow>
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledExpandableContainer>
<SettingsAdminTableCard
items={environmentVariablesDetails}
gridAutoColumns="1fr 4fr"
/>
</StyledExpandableContainer>
</AnimatedExpandableContainer>
</>
);
};

View File

@ -1,51 +0,0 @@
import { SettingsAdminEnvVariablesRow } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesRow';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { useState } from 'react';
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
type SettingsAdminEnvVariablesTableProps = {
variables: Array<{
name: string;
description: string;
value: string;
isSensitive: boolean;
}>;
};
export const SettingsAdminEnvVariablesTable = ({
variables,
}: SettingsAdminEnvVariablesTableProps) => {
const [expandedRowName, setExpandedRowName] = useState<string | null>(null);
const handleExpandToggle = (name: string) => {
setExpandedRowName(expandedRowName === name ? null : name);
};
return (
<Table>
<TableRow gridAutoColumns="5fr 4fr 3fr 1fr">
<TableHeader>Name</TableHeader>
<TableHeader>Description</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBody>
{variables.map((variable) => (
<SettingsAdminEnvVariablesRow
key={variable.name}
variable={variable}
isExpanded={expandedRowName === variable.name}
onExpandToggle={handleExpandToggle}
/>
))}
</StyledTableBody>
</Table>
);
};

View File

@ -1,5 +1,5 @@
import { SettingsAdminEnvVariables } from '@/settings/admin-panel/components/SettingsAdminEnvVariables';
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral'; import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
import { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables';
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs'; import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId'; import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus'; import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
@ -15,8 +15,8 @@ export const SettingsAdminTabContent = () => {
switch (activeTabId) { switch (activeTabId) {
case SETTINGS_ADMIN_TABS.GENERAL: case SETTINGS_ADMIN_TABS.GENERAL:
return <SettingsAdminGeneral />; return <SettingsAdminGeneral />;
case SETTINGS_ADMIN_TABS.ENV_VARIABLES: case SETTINGS_ADMIN_TABS.CONFIG_VARIABLES:
return <SettingsAdminEnvVariables />; return <SettingsAdminConfigVariables />;
case SETTINGS_ADMIN_TABS.HEALTH_STATUS: case SETTINGS_ADMIN_TABS.HEALTH_STATUS:
return <SettingsAdminHealthStatus />; return <SettingsAdminHealthStatus />;
default: default:

View File

@ -0,0 +1,62 @@
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import {
IconDeviceFloppy,
IconPencil,
IconRefreshAlert,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { ConfigSource, ConfigVariable } from '~/generated/graphql';
type ConfigVariableActionButtonsProps = {
variable: ConfigVariable;
isValueValid: boolean;
isSubmitting: boolean;
onSave: () => void;
onReset: () => void;
};
export const ConfigVariableActionButtons = ({
variable,
isValueValid,
isSubmitting,
onSave,
onReset,
}: ConfigVariableActionButtonsProps) => {
const { t } = useLingui();
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const isFromDatabase = variable.source === ConfigSource.DATABASE;
return (
<>
{isConfigVariablesInDbEnabled &&
variable.source === ConfigSource.DATABASE && (
<Button
title={t`Reset to Default`}
variant="secondary"
size="small"
accent="danger"
disabled={isSubmitting}
onClick={onReset}
Icon={IconRefreshAlert}
/>
)}
{isConfigVariablesInDbEnabled && !variable.isEnvOnly && (
<Button
title={isFromDatabase ? t`Save` : t`Edit`}
variant="primary"
size="small"
accent="blue"
disabled={isSubmitting || !isValueValid}
onClick={onSave}
type="submit"
Icon={isFromDatabase ? IconDeviceFloppy : IconPencil}
/>
)}
</>
);
};

View File

@ -0,0 +1,190 @@
import { Select } from '@/ui/input/components/Select';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ConfigVariableValue } from 'twenty-shared/types';
import { MenuItemMultiSelect } from 'twenty-ui/navigation';
import { ConfigVariableType } from '~/generated/graphql';
import { ConfigVariableOptions } from '../types/ConfigVariableOptions';
type ConfigVariableDatabaseInputProps = {
label: string;
value: ConfigVariableValue;
onChange: (value: string | number | boolean | string[] | null) => void;
type: ConfigVariableType;
options?: ConfigVariableOptions;
disabled?: boolean;
placeholder?: string;
};
export const ConfigVariableDatabaseInput = ({
label,
value,
onChange,
type,
options,
disabled,
placeholder,
}: ConfigVariableDatabaseInputProps) => {
const selectOptions =
options && Array.isArray(options)
? options.map((option) => ({
value: String(option),
label: String(option),
}))
: [];
const booleanOptions = [
{ value: 'true', label: 'true' },
{ value: 'false', label: 'false' },
];
const isValueSelected = (optionValue: string) => {
if (!Array.isArray(value)) return false;
return value.includes(optionValue);
};
const handleMultiSelectChange = (optionValue: string) => {
if (!Array.isArray(value)) return;
let newValues = [...value];
if (isValueSelected(optionValue)) {
newValues = newValues.filter((val) => val !== optionValue);
} else {
newValues.push(optionValue);
}
onChange(newValues);
};
switch (type) {
case ConfigVariableType.BOOLEAN:
return (
<Select
label={label}
value={String(value ?? '')}
onChange={(newValue: string) => onChange(newValue === 'true')}
disabled={disabled}
options={booleanOptions}
dropdownId="config-variable-boolean-select"
fullWidth
/>
);
case ConfigVariableType.NUMBER:
return (
<TextInputV2
label={label}
value={value !== null && value !== undefined ? String(value) : ''}
onChange={(text) => {
const num = Number(text);
onChange(isNaN(num) ? text : num);
}}
disabled={disabled}
placeholder={placeholder}
type="number"
fullWidth
/>
);
case ConfigVariableType.ARRAY:
return (
<>
{options && Array.isArray(options) ? (
<Dropdown
dropdownId="config-variable-array-dropdown"
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
dropdownPlacement="bottom-start"
dropdownOffset={{
y: 8,
}}
clickableComponent={
<SelectControl
selectedOption={{
value: '',
label:
Array.isArray(value) && value.length > 0
? value.join(', ')
: 'Select options',
}}
isDisabled={disabled}
hasRightElement={false}
selectSizeVariant="default"
/>
}
dropdownComponents={
<DropdownMenuItemsContainer>
{selectOptions.map((option) => (
<MenuItemMultiSelect
key={option.value}
text={option.label}
selected={isValueSelected(option.value)}
className="config-variable-array-menu-item-multi-select"
onSelectChange={() =>
handleMultiSelectChange(option.value)
}
/>
))}
</DropdownMenuItemsContainer>
}
/>
) : (
<TextArea
label={label}
value={
Array.isArray(value)
? JSON.stringify(value)
: String(value ?? '')
}
onChange={(text) => {
try {
const arr = JSON.parse(text);
onChange(Array.isArray(arr) ? arr : value);
} catch {
onChange(text);
}
}}
disabled={disabled}
placeholder={placeholder || 'Enter JSON array'}
/>
)}
</>
);
case ConfigVariableType.ENUM:
return (
<Select
label={label}
value={String(value ?? '')}
onChange={(newValue: string) => onChange(newValue)}
disabled={disabled}
options={selectOptions}
dropdownId="config-variable-enum-select"
fullWidth
/>
);
case ConfigVariableType.STRING:
return (
<TextInputV2
label={label}
value={
typeof value === 'string'
? value
: value !== null && value !== undefined
? JSON.stringify(value)
: ''
}
onChange={(text) => onChange(text)}
disabled={disabled}
placeholder={placeholder || 'Enter value'}
fullWidth
/>
);
default:
throw new Error(`Unsupported type: ${type}`);
}
};

View File

@ -0,0 +1,44 @@
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import styled from '@emotion/styled';
const StyledChipContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
`;
type ConfigVariableFilterContainerProps = {
children: React.ReactNode;
activeChips: {
label: string;
onRemove: () => void;
source?: ConfigVariableSourceFilter;
variant?: 'default' | 'danger';
}[];
};
export const ConfigVariableFilterContainer = ({
children,
activeChips,
}: ConfigVariableFilterContainerProps) => {
return (
<div>
{children}
{activeChips.length > 0 && (
<StyledChipContainer>
{activeChips.map((chip) => (
<SortOrFilterChip
key={chip.label + chip.source}
labelKey={chip.label}
onRemove={chip.onRemove}
labelValue={chip.source ?? ''}
variant={chip.variant}
/>
))}
</StyledChipContainer>
)}
</div>
);
};

View File

@ -0,0 +1,66 @@
import { ConfigVariableFilterCategory } from '@/settings/admin-panel/config-variables/types/ConfigVariableFilterCategory';
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useState } from 'react';
import { IconSettings } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { ConfigVariableOptionsDropdownContent } from './ConfigVariableOptionsDropdownContent';
type ConfigVariableFilterDropdownProps = {
sourceFilter: ConfigVariableSourceFilter;
groupFilter: ConfigVariableGroupFilter;
groupOptions: { value: string; label: string }[];
showHiddenGroupVariables: boolean;
onSourceFilterChange: (source: ConfigVariableSourceFilter) => void;
onGroupFilterChange: (group: ConfigVariableGroupFilter) => void;
onShowHiddenChange: (value: boolean) => void;
};
export const ConfigVariableFilterDropdown = ({
sourceFilter,
groupFilter,
groupOptions,
showHiddenGroupVariables,
onSourceFilterChange,
onGroupFilterChange,
onShowHiddenChange,
}: ConfigVariableFilterDropdownProps) => {
const [selectedCategory, setSelectedCategory] =
useState<ConfigVariableFilterCategory | null>(null);
const handleSelectCategory = (
category: ConfigVariableFilterCategory | null,
) => {
setSelectedCategory(category);
};
return (
<Dropdown
clickableComponent={
<Button
variant="secondary"
size="medium"
title="Options"
Icon={IconSettings}
/>
}
dropdownId="env-var-options-dropdown"
dropdownHotkeyScope={{ scope: 'env-var-options' }}
dropdownOffset={{ x: 0, y: 10 }}
dropdownComponents={
<ConfigVariableOptionsDropdownContent
selectedCategory={selectedCategory}
onSelectCategory={handleSelectCategory}
sourceFilter={sourceFilter}
groupFilter={groupFilter}
groupOptions={groupOptions}
showHiddenGroupVariables={showHiddenGroupVariables}
onSourceFilterChange={onSourceFilterChange}
onGroupFilterChange={onGroupFilterChange}
onShowHiddenChange={onShowHiddenChange}
/>
}
/>
);
};

View File

@ -0,0 +1,85 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { useRecoilValue } from 'recoil';
import { ConfigSource, ConfigVariable } from '~/generated/graphql';
const StyledHelpText = styled.div<{ color?: string }>`
color: ${({ theme, color }) => color || theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
line-height: 1.5;
`;
type ConfigVariableHelpTextProps = {
variable: ConfigVariable;
hasValueChanged: boolean;
color?: string;
};
export const ConfigVariableHelpText = ({
variable,
hasValueChanged,
}: ConfigVariableHelpTextProps) => {
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const { t } = useLingui();
const isFromDatabase = variable.source === ConfigSource.DATABASE;
const isFromEnvironment = variable.source === ConfigSource.ENVIRONMENT;
const isReadOnly = !isConfigVariablesInDbEnabled;
if (isReadOnly) {
return (
<StyledHelpText>
{t`Database configuration is currently disabled.`}{' '}
{isFromEnvironment
? t`Value is set in the server environment, it may be a different value on the worker.`
: t`Using default application value. Configure via environment variables.`}
</StyledHelpText>
);
}
if (isConfigVariablesInDbEnabled && variable.isEnvOnly) {
return (
<StyledHelpText>
{t`This setting can only be configured through environment variables.`}
</StyledHelpText>
);
}
if (isConfigVariablesInDbEnabled && !variable.isEnvOnly && hasValueChanged) {
return (
<StyledHelpText>
{isFromDatabase
? t`Click on the checkmark to apply your changes.`
: t`This value will be saved to the database.`}
</StyledHelpText>
);
}
if (isConfigVariablesInDbEnabled && !variable.isEnvOnly && !hasValueChanged) {
if (isFromDatabase) {
return (
<>
<StyledHelpText>
{t`This database value overrides environment settings. `}
</StyledHelpText>
<StyledHelpText>
{t`Clear the field or "X" to revert to environment/default value.`}
</StyledHelpText>
</>
);
} else {
return (
<StyledHelpText>
{isFromEnvironment
? t`Current value from server environment. Set a custom value to override.`
: t`Using default value. Set a custom value to override.`}
</StyledHelpText>
);
}
}
return <StyledHelpText>{t`This should never happen`}</StyledHelpText>;
};

View File

@ -0,0 +1,141 @@
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { ConfigVariableSourceOptions } from '@/settings/admin-panel/config-variables/constants/ConfigVariableSourceOptions';
import { ConfigVariableFilterCategory } from '@/settings/admin-panel/config-variables/types/ConfigVariableFilterCategory';
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconChevronLeft, IconEye, IconEyeOff } from 'twenty-ui/display';
import { MenuItem, MenuItemSelectTag } from 'twenty-ui/navigation';
type ConfigVariableOptionsDropdownContentProps = {
selectedCategory: ConfigVariableFilterCategory | null;
onSelectCategory: (category: ConfigVariableFilterCategory | null) => void;
sourceFilter: ConfigVariableSourceFilter;
groupFilter: ConfigVariableGroupFilter;
groupOptions: { value: ConfigVariableGroupFilter; label: string }[];
showHiddenGroupVariables: boolean;
onSourceFilterChange: (source: ConfigVariableSourceFilter) => void;
onGroupFilterChange: (group: ConfigVariableGroupFilter) => void;
onShowHiddenChange: (value: boolean) => void;
};
export const ConfigVariableOptionsDropdownContent = ({
selectedCategory,
onSelectCategory,
sourceFilter,
groupFilter,
groupOptions,
showHiddenGroupVariables,
onSourceFilterChange,
onGroupFilterChange,
onShowHiddenChange,
}: ConfigVariableOptionsDropdownContentProps) => {
const theme = useTheme();
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const availableSourceOptions = ConfigVariableSourceOptions.filter(
(option) => isConfigVariablesInDbEnabled || option.value !== 'database',
);
if (!selectedCategory) {
return (
<>
<DropdownMenuItemsContainer>
<MenuItemSelectTag
text={t`Source`}
color="transparent"
onClick={() => onSelectCategory('source')}
/>
<MenuItemSelectTag
text={t`Group`}
color="transparent"
onClick={() => onSelectCategory('group')}
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
text={
showHiddenGroupVariables
? t`Hide hidden groups`
: t`Show hidden groups`
}
LeftIcon={() =>
showHiddenGroupVariables ? (
<IconEyeOff
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
) : (
<IconEye
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)
}
onClick={() => onShowHiddenChange(!showHiddenGroupVariables)}
/>
</DropdownMenuItemsContainer>
</>
);
}
return (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={() => onSelectCategory(null)}
Icon={IconChevronLeft}
/>
}
>
{selectedCategory === 'source' && t`Select Source`}
{selectedCategory === 'group' && t`Select Group`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{selectedCategory === 'source' && (
<>
{availableSourceOptions.map((option) => (
<MenuItemSelectTag
key={option.value}
text={option.label}
color={option.color}
selected={option.value === sourceFilter}
onClick={() => {
onSourceFilterChange(option.value);
onSelectCategory(null);
}}
/>
))}
</>
)}
{selectedCategory === 'group' && (
<>
{groupOptions.map((option) => (
<MenuItemSelectTag
key={option.value}
text={option.label}
color="transparent"
selected={option.value === groupFilter}
onClick={() => {
onGroupFilterChange(option.value);
onSelectCategory(null);
}}
/>
))}
</>
)}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,28 @@
import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSearch } from 'twenty-ui/display';
const StyledSearchInput = styled(TextInput)`
width: 100%;
`;
type ConfigVariableSearchInputProps = {
value: string;
onChange: (value: string) => void;
};
export const ConfigVariableSearchInput = ({
value,
onChange,
}: ConfigVariableSearchInputProps) => {
return (
<StyledSearchInput
placeholder={t`Search config variables`}
value={value}
onChange={onChange}
autoFocus={false}
LeftIcon={IconSearch}
/>
);
};

View File

@ -0,0 +1,57 @@
import { useLingui } from '@lingui/react/macro';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { ConfigVariableValue } from 'twenty-shared/types';
import { ConfigVariable } from '~/generated/graphql';
import { ConfigVariableDatabaseInput } from './ConfigVariableDatabaseInput';
type ConfigVariableValueInputProps = {
variable: ConfigVariable;
value: ConfigVariableValue;
onChange: (value: string | number | boolean | string[] | null) => void;
disabled?: boolean;
};
const StyledValueContainer = styled.div`
width: 100%;
`;
export const ConfigVariableValueInput = ({
variable,
value,
onChange,
disabled,
}: ConfigVariableValueInputProps) => {
const { t } = useLingui();
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
return (
<StyledValueContainer>
{isConfigVariablesInDbEnabled && !variable.isEnvOnly ? (
<ConfigVariableDatabaseInput
label={t`Value`}
value={value}
onChange={onChange}
type={variable.type}
options={variable.options}
disabled={disabled}
placeholder={
disabled ? 'Undefined' : t`Enter a value to store in database`
}
/>
) : (
<TextInputV2
value={String(value)}
disabled
label={t`Value`}
fullWidth
/>
)}
</StyledValueContainer>
);
};

View File

@ -3,8 +3,15 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useDebouncedCallback } from 'use-debounce';
import { IconCopy, OverflowingTextWithTooltip } from 'twenty-ui/display'; import { IconCopy, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
type SettingsAdminConfigCopyableTextProps = {
text: string;
displayText?: React.ReactNode;
multiline?: boolean;
maxRows?: number;
};
const StyledEllipsisLabel = styled.div` const StyledEllipsisLabel = styled.div`
white-space: nowrap; white-space: nowrap;
@ -20,17 +27,12 @@ const StyledExpandedEllipsisLabel = styled.div`
const StyledCopyContainer = styled.span` const StyledCopyContainer = styled.span`
cursor: pointer; cursor: pointer;
`; `;
export const SettingsAdminEnvCopyableText = ({ export const SettingsAdminConfigCopyableText = ({
text, text,
displayText, displayText,
multiline = false, multiline = false,
maxRows, maxRows,
}: { }: SettingsAdminConfigCopyableTextProps) => {
text: string;
displayText?: React.ReactNode;
multiline?: boolean;
maxRows?: number;
}) => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const theme = useTheme(); const theme = useTheme();
const { t } = useLingui(); const { t } = useLingui();

View File

@ -0,0 +1,207 @@
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { ConfigVariableFilterContainer } from '@/settings/admin-panel/config-variables/components/ConfigVariableFilterContainer';
import { ConfigVariableFilterDropdown } from '@/settings/admin-panel/config-variables/components/ConfigVariableFilterDropdown';
import { SettingsAdminConfigVariablesTable } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariablesTable';
import { ConfigVariableSourceOptions } from '@/settings/admin-panel/config-variables/constants/ConfigVariableSourceOptions';
import { ConfigVariableGroupFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableGroupFilter';
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
ConfigSource,
useGetConfigVariablesGroupedQuery,
} from '~/generated/graphql';
import { ConfigVariableSearchInput } from './ConfigVariableSearchInput';
const StyledControlsContainer = styled.div`
display: flex;
gap: 8px;
justify-content: space-between;
`;
const StyledTableContainer = styled.div`
margin-bottom: 24px;
`;
export const SettingsAdminConfigVariables = () => {
const { data: configVariables, loading: configVariablesLoading } =
useGetConfigVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const [search, setSearch] = useState('');
const [showHiddenGroupVariables, setShowHiddenGroupVariables] =
useState(false);
const [sourceFilter, setSourceFilter] =
useState<ConfigVariableSourceFilter>('all');
const [groupFilter, setGroupFilter] =
useState<ConfigVariableGroupFilter>('all');
// Get all groups, not filtered by visibility
const allGroups = useMemo(
() => configVariables?.getConfigVariablesGrouped.groups ?? [],
[configVariables],
);
// Compute group options from all groups, not just visible ones
const groupOptions = useMemo(
() => [
{ value: 'all', label: 'All Groups' },
...allGroups.map((group) => ({
value: group.name,
label: group.name,
})),
],
[allGroups],
);
// Flatten all variables for filtering, attaching isHiddenOnLoad and groupName from group
const allVariables = useMemo(
() =>
configVariables?.getConfigVariablesGrouped.groups.flatMap((group) =>
group.variables.map((variable) => ({
...variable,
isHiddenOnLoad: group.isHiddenOnLoad,
groupName: group.name,
})),
) ?? [],
[configVariables],
);
// Filtering logic
const filteredVariables = useMemo(() => {
const isSearching = search.trim().length > 0;
const hasSelectedSpecificGroup = groupFilter !== 'all';
return allVariables.filter((v) => {
// Search filter
const matchesSearch =
v.name.toLowerCase().includes(search.toLowerCase()) ||
(v.description?.toLowerCase() || '').includes(search.toLowerCase());
if (isSearching && !matchesSearch) return false;
// Group filter
const matchesGroup = hasSelectedSpecificGroup
? v.groupName === groupFilter
: true;
if (hasSelectedSpecificGroup && !matchesGroup) return false;
// Hidden filter - Only apply if:
// 1. User is not searching
// 2. Show hidden is off
// 3. Item is from a hidden group
// 4. No specific group is selected (if a specific group is selected, show all its variables)
if (
!isSearching &&
!showHiddenGroupVariables &&
v.isHiddenOnLoad &&
!hasSelectedSpecificGroup
) {
return false;
}
// Source filter
let matchesSource = true;
if (sourceFilter === 'database')
matchesSource = v.source === ConfigSource.DATABASE;
if (sourceFilter === 'environment')
matchesSource = v.source === ConfigSource.ENVIRONMENT;
if (sourceFilter === 'default')
matchesSource = v.source === ConfigSource.DEFAULT;
return matchesSource;
});
}, [
allVariables,
search,
showHiddenGroupVariables,
sourceFilter,
groupFilter,
]);
// Build activeChips for current filters
const activeChips = [];
if (sourceFilter !== 'all') {
activeChips.push({
label:
ConfigVariableSourceOptions.find((o) => o.value === sourceFilter)
?.label || '',
onRemove: () => setSourceFilter('all'),
variant: 'default' as const,
});
}
if (groupFilter !== 'all') {
activeChips.push({
label: groupOptions.find((o) => o.value === groupFilter)?.label || '',
onRemove: () => setGroupFilter('all'),
variant: 'danger' as const,
});
}
// Group variables by groupName for rendering
const groupedVariables = useMemo(() => {
const groupMap = new Map();
filteredVariables.forEach((v) => {
if (!groupMap.has(v.groupName)) {
const group = allGroups.find((g) => g.name === v.groupName);
groupMap.set(v.groupName, {
variables: [],
description: group?.description || '',
});
}
groupMap.get(v.groupName).variables.push(v);
});
return groupMap;
}, [filteredVariables, allGroups]);
if (configVariablesLoading) {
return <SettingsAdminTabSkeletonLoader />;
}
return (
<>
<Section>
<H2Title title={t`Config Variables`} />
<ConfigVariableFilterContainer activeChips={activeChips}>
<StyledControlsContainer>
<ConfigVariableSearchInput value={search} onChange={setSearch} />
<ConfigVariableFilterDropdown
sourceFilter={sourceFilter}
groupFilter={groupFilter}
groupOptions={groupOptions}
showHiddenGroupVariables={showHiddenGroupVariables}
onSourceFilterChange={setSourceFilter}
onGroupFilterChange={setGroupFilter}
onShowHiddenChange={setShowHiddenGroupVariables}
/>
</StyledControlsContainer>
</ConfigVariableFilterContainer>
</Section>
{groupedVariables.size === 0 && (
<StyledTableContainer>
<Section>
<H2Title
title={t`No variables found`}
description={t`No config variables match your current filters. Try adjusting your filters or search criteria.`}
/>
</Section>
</StyledTableContainer>
)}
{[...groupedVariables.entries()].map(([groupName, groupData]) => (
<StyledTableContainer key={groupName}>
<H2Title title={groupName} description={groupData.description} />
<SettingsAdminConfigVariablesTable variables={groupData.variables} />
</StyledTableContainer>
))}
</>
);
};

View File

@ -0,0 +1,70 @@
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronRight } from 'twenty-ui/display';
import { ConfigVariable } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsAdminConfigVariablesRowProps = {
variable: ConfigVariable;
};
const StyledTruncatedCell = styled(TableCell)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
`;
const StyledTableRow = styled(TableRow)`
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledEllipsisLabel = styled.div`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const SettingsAdminConfigVariablesRow = ({
variable,
}: SettingsAdminConfigVariablesRowProps) => {
const theme = useTheme();
const displayValue =
variable.value === ''
? 'null'
: variable.isSensitive
? '••••••'
: typeof variable.value === 'boolean'
? variable.value
? 'true'
: 'false'
: variable.value;
return (
<StyledTableRow
gridAutoColumns="5fr 3fr 1fr"
to={getSettingsPath(SettingsPath.AdminPanelConfigVariableDetails, {
variableName: variable.name,
})}
>
<StyledTruncatedCell color={theme.font.color.primary}>
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
</StyledTruncatedCell>
<StyledTruncatedCell align="right">
<StyledEllipsisLabel>{displayValue}</StyledEllipsisLabel>
</StyledTruncatedCell>
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</StyledTableRow>
);
};

View File

@ -0,0 +1,37 @@
import { SettingsAdminConfigVariablesRow } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariablesRow';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { ConfigVariable } from '~/generated/graphql';
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
type SettingsAdminConfigVariablesTableProps = {
variables: ConfigVariable[];
};
export const SettingsAdminConfigVariablesTable = ({
variables,
}: SettingsAdminConfigVariablesTableProps) => {
return (
<Table>
<TableRow gridAutoColumns="5fr 3fr 1fr">
<TableHeader>Name</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBody>
{variables.map((variable) => (
<SettingsAdminConfigVariablesRow
key={variable.name}
variable={variable}
/>
))}
</StyledTableBody>
</Table>
);
};

View File

@ -0,0 +1,15 @@
import { ConfigVariableSourceFilter } from '@/settings/admin-panel/config-variables/types/ConfigVariableSourceFilter';
import { ThemeColor } from 'twenty-ui/theme';
type ConfigVariableSourceOption = {
value: ConfigVariableSourceFilter;
label: string;
color: ThemeColor | 'transparent';
};
export const ConfigVariableSourceOptions: ConfigVariableSourceOption[] = [
{ value: 'all', label: 'All Sources', color: 'transparent' },
{ value: 'database', label: 'Database', color: 'blue' },
{ value: 'environment', label: 'Environment', color: 'green' },
{ value: 'default', label: 'Default', color: 'gray' },
];

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const CREATE_DATABASE_CONFIG_VARIABLE = gql`
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
createDatabaseConfigVariable(key: $key, value: $value)
}
`;

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_DATABASE_CONFIG_VARIABLE = gql`
mutation DeleteDatabaseConfigVariable($key: String!) {
deleteDatabaseConfigVariable(key: $key)
}
`;

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const UPDATE_DATABASE_CONFIG_VARIABLE = gql`
mutation UpdateDatabaseConfigVariable($key: String!, $value: JSON!) {
updateDatabaseConfigVariable(key: $key, value: $value)
}
`;

View File

@ -12,6 +12,10 @@ export const GET_CONFIG_VARIABLES_GROUPED = gql`
description description
value value
isSensitive isSensitive
isEnvOnly
type
options
source
} }
} }
} }

View File

@ -0,0 +1,16 @@
import { gql } from '@apollo/client';
export const GET_DATABASE_CONFIG_VARIABLE = gql`
query GetDatabaseConfigVariable($key: String!) {
getDatabaseConfigVariable(key: $key) {
name
description
value
isSensitive
isEnvOnly
type
options
source
}
}
`;

View File

@ -0,0 +1,115 @@
import { useLingui } from '@lingui/react/macro';
import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig';
import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfigVariableValue } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
useCreateDatabaseConfigVariableMutation,
useDeleteDatabaseConfigVariableMutation,
useUpdateDatabaseConfigVariableMutation,
} from '~/generated/graphql';
export const useConfigVariableActions = (variableName: string) => {
const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar();
const [updateDatabaseConfigVariable] =
useUpdateDatabaseConfigVariableMutation();
const [createDatabaseConfigVariable] =
useCreateDatabaseConfigVariableMutation();
const [deleteDatabaseConfigVariable] =
useDeleteDatabaseConfigVariableMutation();
const handleUpdateVariable = async (
value: ConfigVariableValue,
isFromDatabase: boolean,
) => {
try {
if (
value === null ||
(typeof value === 'string' && value === '') ||
(Array.isArray(value) && value.length === 0)
) {
await handleDeleteVariable();
return;
}
if (isFromDatabase) {
await updateDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
{
query: GET_CLIENT_CONFIG,
},
],
});
} else {
await createDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
{
query: GET_CLIENT_CONFIG,
},
],
});
}
enqueueSnackBar(t`Variable updated successfully`, {
variant: SnackBarVariant.Success,
});
} catch (error) {
enqueueSnackBar(t`Failed to update variable`, {
variant: SnackBarVariant.Error,
});
}
};
const handleDeleteVariable = async (e?: React.MouseEvent<HTMLElement>) => {
if (isDefined(e)) {
e.preventDefault();
}
try {
await deleteDatabaseConfigVariable({
variables: {
key: variableName,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
{
query: GET_CLIENT_CONFIG,
},
],
});
} catch (error) {
enqueueSnackBar(t`Failed to remove override`, {
variant: SnackBarVariant.Error,
});
}
};
return {
handleUpdateVariable,
handleDeleteVariable,
};
};

View File

@ -0,0 +1,54 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { ConfigVariableValue } from 'twenty-shared/types';
import { z } from 'zod';
import { ConfigVariable } from '~/generated/graphql';
type FormValues = {
value: ConfigVariableValue;
};
export const useConfigVariableForm = (variable?: ConfigVariable) => {
const validationSchema = z.object({
value: z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.null(),
]),
});
const {
handleSubmit,
setValue,
formState: { isSubmitting },
watch,
} = useForm<FormValues>({
resolver: zodResolver(validationSchema),
values: { value: variable?.value ?? null },
});
const currentValue = watch('value');
const hasValueChanged = currentValue !== variable?.value;
const isValueValid = !!(
variable &&
!variable.isEnvOnly &&
hasValueChanged &&
((typeof currentValue === 'string' && currentValue.trim() !== '') ||
typeof currentValue === 'boolean' ||
typeof currentValue === 'number' ||
(Array.isArray(currentValue) && currentValue.length > 0))
);
return {
handleSubmit,
setValue,
isSubmitting,
watch,
currentValue,
hasValueChanged,
isValueValid,
};
};

View File

@ -0,0 +1 @@
export type ConfigVariableFilterCategory = 'source' | 'group';

View File

@ -0,0 +1 @@
export type ConfigVariableGroupFilter = 'all' | string;

View File

@ -0,0 +1,3 @@
export type ConfigVariableOptions =
| readonly (string | number | boolean)[]
| Record<string, string>;

View File

@ -0,0 +1,5 @@
export type ConfigVariableSourceFilter =
| 'all'
| 'database'
| 'environment'
| 'default';

View File

@ -0,0 +1,29 @@
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { ConfigSource } from '~/generated/graphql';
export const useSourceContent = (source: ConfigSource) => {
const { t } = useLingui();
const theme = useTheme();
switch (source) {
case ConfigSource.DATABASE:
return {
text: t`Stored in database`,
color: theme.color.blue50,
};
case ConfigSource.ENVIRONMENT:
return {
text: t`Environment variable`,
color: theme.color.green50,
};
case ConfigSource.DEFAULT:
return {
text: t`Default value`,
color: theme.font.color.tertiary,
};
default:
throw new Error(`Unknown source: ${source}`);
}
};

View File

@ -1,5 +1,5 @@
export const SETTINGS_ADMIN_TABS = { export const SETTINGS_ADMIN_TABS = {
GENERAL: 'general', GENERAL: 'general',
ENV_VARIABLES: 'env-variables', CONFIG_VARIABLES: 'config-variables',
HEALTH_STATUS: 'health-status', HEALTH_STATUS: 'health-status',
}; };

View File

@ -3,11 +3,11 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { BOOLEAN_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/forms/boolean/constants/BooleanDataModelSelectOptions';
import { useBooleanSettingsFormInitialValues } from '@/settings/data-model/fields/forms/boolean/hooks/useBooleanSettingsFormInitialValues'; import { useBooleanSettingsFormInitialValues } from '@/settings/data-model/fields/forms/boolean/hooks/useBooleanSettingsFormInitialValues';
import { Select } from '@/ui/input/components/Select'; import { Select } from '@/ui/input/components/Select';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { IconCheck } from 'twenty-ui/display'; import { IconCheck } from 'twenty-ui/display';
import { BOOLEAN_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/forms/boolean/constants/BooleanDataModelSelectOptions';
export const settingsDataModelFieldBooleanFormSchema = z.object({ export const settingsDataModelFieldBooleanFormSchema = z.object({
defaultValue: z.boolean(), defaultValue: z.boolean(),

View File

@ -39,7 +39,7 @@ export enum SettingsPath {
AdminPanel = 'admin-panel', AdminPanel = 'admin-panel',
AdminPanelHealthStatus = 'admin-panel#health-status', AdminPanelHealthStatus = 'admin-panel#health-status',
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorId', AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorId',
AdminPanelOtherEnvVariables = 'admin-panel/other-env-variables', AdminPanelConfigVariableDetails = 'admin-panel/config-variables/:variableName',
Lab = 'lab', Lab = 'lab',
Roles = 'roles', Roles = 'roles',
RoleCreate = 'roles/create', RoleCreate = 'roles/create',

View File

@ -0,0 +1,211 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { Form, useParams } from 'react-router-dom';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import { ConfigVariableHelpText } from '@/settings/admin-panel/config-variables/components/ConfigVariableHelpText';
import { ConfigVariableValueInput } from '@/settings/admin-panel/config-variables/components/ConfigVariableValueInput';
import { useConfigVariableActions } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableActions';
import { useConfigVariableForm } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableForm';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useRecoilValue } from 'recoil';
import { ConfigVariableValue } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { H3Title, IconCheck, IconPencil, IconX } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
ConfigSource,
useGetDatabaseConfigVariableQuery,
} from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledForm = styled(Form)`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const StyledH3Title = styled(H3Title)`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledRow = styled.div`
display: flex;
align-items: flex-end;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledButtonContainer = styled.div`
display: flex;
& > :not(:first-of-type) > button {
border-left: none;
}
`;
export const SettingsAdminConfigVariableDetails = () => {
const { variableName } = useParams();
const { t } = useLingui();
const [isEditing, setIsEditing] = useState(false);
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
const { data: configVariableData, loading } =
useGetDatabaseConfigVariableQuery({
variables: { key: variableName ?? '' },
fetchPolicy: 'network-only',
});
const variable = configVariableData?.getDatabaseConfigVariable;
const { handleUpdateVariable, handleDeleteVariable } =
useConfigVariableActions(variable?.name ?? '');
const {
handleSubmit,
setValue,
isSubmitting,
watch,
hasValueChanged,
isValueValid,
} = useConfigVariableForm(variable);
if (loading === true || isDefined(variable) === false) {
return <SettingsSkeletonLoader />;
}
const isEnvOnly = variable.isEnvOnly;
const isFromDatabase = variable.source === ConfigSource.DATABASE;
const onSubmit = async (formData: { value: ConfigVariableValue }) => {
await handleUpdateVariable(formData.value, isFromDatabase);
setIsEditing(false);
};
const handleEditClick = () => {
setIsEditing(true);
};
const handleXButtonClick = () => {
if (isFromDatabase && hasValueChanged) {
setValue('value', variable.value);
setIsEditing(false);
return;
}
if (isFromDatabase && !hasValueChanged) {
setIsConfirmationModalOpen(true);
return;
}
setValue('value', variable.value);
setIsEditing(false);
};
const handleConfirmReset = () => {
handleDeleteVariable();
setIsEditing(false);
};
return (
<>
<SubMenuTopBarContainer
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Config Variables`,
href: getSettingsPath(
SettingsPath.AdminPanel,
undefined,
undefined,
'config-variables',
),
},
{
children: variable.name,
},
]}
>
<SettingsPageContainer>
<StyledH3Title
title={variable.name}
description={variable.description}
/>
<StyledForm onSubmit={handleSubmit(onSubmit)}>
<StyledRow>
<ConfigVariableValueInput
variable={variable}
value={watch('value')}
onChange={(value) => setValue('value', value)}
disabled={isEnvOnly || !isEditing}
/>
{!isEditing ? (
<Button
Icon={IconPencil}
variant="primary"
onClick={handleEditClick}
type="button"
disabled={isEnvOnly || !isConfigVariablesInDbEnabled}
/>
) : (
<StyledButtonContainer>
<Button
Icon={IconCheck}
variant="secondary"
position="left"
type="submit"
disabled={isSubmitting || !isValueValid || !hasValueChanged}
/>
<Button
Icon={IconX}
variant="secondary"
position="right"
onClick={handleXButtonClick}
type="button"
disabled={isSubmitting}
/>
</StyledButtonContainer>
)}
</StyledRow>
<ConfigVariableHelpText
variable={variable}
hasValueChanged={hasValueChanged}
/>
</StyledForm>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
isOpen={isConfirmationModalOpen}
setIsOpen={(isOpen) => {
setIsConfirmationModalOpen(isOpen);
if (!isOpen) {
setIsEditing(false);
}
}}
title={t`Reset variable`}
subtitle={t`This will revert the database value to environment/default value. The database override will be removed and the system will use the environment settings.`}
onConfirmClick={handleConfirmReset}
confirmButtonText={t`Reset`}
confirmButtonAccent="danger"
/>
</>
);
};

View File

@ -1,60 +0,0 @@
import { SettingsAdminEnvVariablesTable } from '@/settings/admin-panel/components/SettingsAdminEnvVariablesTable';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { useGetConfigVariablesGroupedQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledGroupContainer = styled.div``;
export const SettingsAdminSecondaryEnvVariables = () => {
const {
data: secondaryConfigVariables,
loading: secondaryConfigVariablesLoading,
} = useGetConfigVariablesGroupedQuery({
fetchPolicy: 'network-only',
});
const hiddenGroups =
secondaryConfigVariables?.getConfigVariablesGrouped.groups.filter(
(group) => group.isHiddenOnLoad,
) ?? [];
if (secondaryConfigVariablesLoading) {
return <SettingsSkeletonLoader />;
}
return (
<SubMenuTopBarContainer
title={t`Other Environment Variables`}
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Other Environment Variables`,
},
]}
>
<SettingsPageContainer>
{hiddenGroups.map((group) => (
<StyledGroupContainer key={group.name}>
<H2Title title={group.name} description={group.description} />
{group.variables.length > 0 && (
<SettingsAdminEnvVariablesTable variables={group.variables} />
)}
</StyledGroupContainer>
))}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -58,4 +58,5 @@ export const mockedClientConfig: ClientConfig = {
isGoogleMessagingEnabled: true, isGoogleMessagingEnabled: true,
isGoogleCalendarEnabled: true, isGoogleCalendarEnabled: true,
isAttachmentPreviewEnabled: true, isAttachmentPreviewEnabled: true,
isConfigVariablesInDbEnabled: false,
}; };

View File

@ -10,6 +10,7 @@ export const getSettingsPath = <T extends SettingsPath>(
[key in PathParam<`/${AppPath.Settings}/${T}`>]: string | null; [key in PathParam<`/${AppPath.Settings}/${T}`>]: string | null;
}, },
queryParams?: Record<string, any>, queryParams?: Record<string, any>,
hash?: string,
) => { ) => {
let path = `/${AppPath.Settings}/${to}`; let path = `/${AppPath.Settings}/${to}`;
@ -32,5 +33,9 @@ export const getSettingsPath = <T extends SettingsPath>(
} }
} }
if (isDefined(hash)) {
path += `#${hash.replace(/^#/, '')}`;
}
return path; return path;
}; };

View File

@ -16,6 +16,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
const UserFindOneMock = jest.fn(); const UserFindOneMock = jest.fn();
const LoginTokenServiceGenerateLoginTokenMock = jest.fn(); const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
const TwentyConfigServiceGetAllMock = jest.fn(); const TwentyConfigServiceGetAllMock = jest.fn();
const TwentyConfigServiceGetVariableWithMetadataMock = jest.fn();
jest.mock( jest.mock(
'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata', 'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata',
@ -72,6 +73,8 @@ describe('AdminPanelService', () => {
provide: TwentyConfigService, provide: TwentyConfigService,
useValue: { useValue: {
getAll: TwentyConfigServiceGetAllMock, getAll: TwentyConfigServiceGetAllMock,
getVariableWithMetadata:
TwentyConfigServiceGetVariableWithMetadataMock,
}, },
}, },
], ],
@ -165,14 +168,20 @@ describe('AdminPanelService', () => {
metadata: { metadata: {
group: 'SERVER_CONFIG', group: 'SERVER_CONFIG',
description: 'Server URL', description: 'Server URL',
type: 'string',
options: undefined,
}, },
source: 'env',
}, },
RATE_LIMIT_TTL: { RATE_LIMIT_TTL: {
value: '60', value: 60,
metadata: { metadata: {
group: 'RATE_LIMITING', group: 'RATE_LIMITING',
description: 'Rate limit TTL', description: 'Rate limit TTL',
type: 'number',
options: undefined,
}, },
source: 'env',
}, },
API_KEY: { API_KEY: {
value: 'secret-key', value: 'secret-key',
@ -180,14 +189,20 @@ describe('AdminPanelService', () => {
group: 'SERVER_CONFIG', group: 'SERVER_CONFIG',
description: 'API Key', description: 'API Key',
isSensitive: true, isSensitive: true,
type: 'string',
options: undefined,
}, },
source: 'env',
}, },
OTHER_VAR: { OTHER_VAR: {
value: 'other', value: 'other',
metadata: { metadata: {
group: 'OTHER', group: 'OTHER',
description: 'Other var', description: 'Other var',
type: 'string',
options: undefined,
}, },
source: 'env',
}, },
}); });
@ -205,12 +220,20 @@ describe('AdminPanelService', () => {
value: 'secret-key', value: 'secret-key',
description: 'API Key', description: 'API Key',
isSensitive: true, isSensitive: true,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
}, },
{ {
name: 'SERVER_URL', name: 'SERVER_URL',
value: 'http://localhost', value: 'http://localhost',
description: 'Server URL', description: 'Server URL',
isSensitive: false, isSensitive: false,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
}, },
], ],
}, },
@ -221,9 +244,13 @@ describe('AdminPanelService', () => {
variables: [ variables: [
{ {
name: 'RATE_LIMIT_TTL', name: 'RATE_LIMIT_TTL',
value: '60', value: 60,
description: 'Rate limit TTL', description: 'Rate limit TTL',
isSensitive: false, isSensitive: false,
isEnvOnly: false,
type: 'number',
options: undefined,
source: 'env',
}, },
], ],
}, },
@ -237,6 +264,10 @@ describe('AdminPanelService', () => {
value: 'other', value: 'other',
description: 'Other var', description: 'Other var',
isSensitive: false, isSensitive: false,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
}, },
], ],
}, },
@ -264,7 +295,10 @@ describe('AdminPanelService', () => {
value: 'test', value: 'test',
metadata: { metadata: {
group: 'SERVER_CONFIG', group: 'SERVER_CONFIG',
type: 'string',
options: undefined,
}, },
source: 'env',
}, },
}); });
@ -275,6 +309,10 @@ describe('AdminPanelService', () => {
value: 'test', value: 'test',
description: undefined, description: undefined,
isSensitive: false, isSensitive: false,
isEnvOnly: false,
options: undefined,
source: 'env',
type: 'string',
}); });
}); });
}); });
@ -376,4 +414,42 @@ describe('AdminPanelService', () => {
}); });
}); });
}); });
describe('getConfigVariable', () => {
it('should return config variable with all fields', () => {
TwentyConfigServiceGetVariableWithMetadataMock.mockReturnValue({
value: 'test-value',
metadata: {
group: 'SERVER_CONFIG',
description: 'Test description',
isSensitive: true,
isEnvOnly: true,
type: 'string',
options: ['option1', 'option2'],
},
source: 'env',
});
const result = service.getConfigVariable('SERVER_URL');
expect(result).toEqual({
name: 'SERVER_URL',
value: 'test-value',
description: 'Test description',
isSensitive: true,
isEnvOnly: true,
type: 'string',
options: ['option1', 'option2'],
source: 'env',
});
});
it('should throw error when variable not found', () => {
TwentyConfigServiceGetVariableWithMetadataMock.mockReturnValue(undefined);
expect(() => service.getConfigVariable('INVALID_VAR')).toThrow(
'Config variable INVALID_VAR not found',
);
});
});
}); });

View File

@ -1,8 +1,11 @@
import { UseFilters, UseGuards } from '@nestjs/common'; import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service'; import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { ConfigVariable } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output'; import { ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input'; import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output'; import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
@ -18,6 +21,9 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum'; import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard'; import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard'; import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
@ -27,12 +33,16 @@ import { AdminPanelHealthServiceData } from './dtos/admin-panel-health-service-d
import { QueueMetricsData } from './dtos/queue-metrics-data.dto'; import { QueueMetricsData } from './dtos/queue-metrics-data.dto';
@Resolver() @Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter) @UseFilters(
AuthGraphqlApiExceptionFilter,
ConfigVariableGraphqlApiExceptionFilter,
)
export class AdminPanelResolver { export class AdminPanelResolver {
constructor( constructor(
private adminService: AdminPanelService, private adminService: AdminPanelService,
private adminPanelHealthService: AdminPanelHealthService, private adminPanelHealthService: AdminPanelHealthService,
private featureFlagService: FeatureFlagService, private featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
) {} ) {}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard) @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@ -119,4 +129,48 @@ export class AdminPanelResolver {
async versionInfo(): Promise<VersionInfo> { async versionInfo(): Promise<VersionInfo> {
return this.adminService.getVersionInfo(); return this.adminService.getVersionInfo();
} }
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Query(() => ConfigVariable)
async getDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
): Promise<ConfigVariable> {
this.twentyConfigService.validateConfigVariableExists(key as string);
return this.adminService.getConfigVariable(key);
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Mutation(() => Boolean)
async createDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
@Args('value', { type: () => GraphQLJSON })
value: ConfigVariables[keyof ConfigVariables],
): Promise<boolean> {
await this.twentyConfigService.set(key, value);
return true;
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Mutation(() => Boolean)
async updateDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
@Args('value', { type: () => GraphQLJSON })
value: ConfigVariables[keyof ConfigVariables],
): Promise<boolean> {
await this.twentyConfigService.update(key, value);
return true;
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Mutation(() => Boolean)
async deleteDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
): Promise<boolean> {
await this.twentyConfigService.delete(key);
return true;
}
} }

View File

@ -18,6 +18,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_GROUP_METADATA } from 'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata'; import { CONFIG_VARIABLES_GROUP_METADATA } from 'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -127,14 +128,20 @@ export class AdminPanelService {
const rawEnvVars = this.twentyConfigService.getAll(); const rawEnvVars = this.twentyConfigService.getAll();
const groupedData = new Map<ConfigVariablesGroup, ConfigVariable[]>(); const groupedData = new Map<ConfigVariablesGroup, ConfigVariable[]>();
for (const [varName, { value, metadata }] of Object.entries(rawEnvVars)) { for (const [varName, { value, metadata, source }] of Object.entries(
rawEnvVars,
)) {
const { group, description } = metadata; const { group, description } = metadata;
const envVar: ConfigVariable = { const envVar: ConfigVariable = {
name: varName, name: varName,
description, description,
value: String(value), value: value ?? null,
isSensitive: metadata.isSensitive ?? false, isSensitive: metadata.isSensitive ?? false,
isEnvOnly: metadata.isEnvOnly ?? false,
type: metadata.type,
options: metadata.options,
source,
}; };
if (!groupedData.has(group)) { if (!groupedData.has(group)) {
@ -161,6 +168,30 @@ export class AdminPanelService {
return { groups }; return { groups };
} }
getConfigVariable(key: string): ConfigVariable {
const variableWithMetadata =
this.twentyConfigService.getVariableWithMetadata(
key as keyof ConfigVariables,
);
if (!variableWithMetadata) {
throw new Error(`Config variable ${key} not found`);
}
const { value, metadata, source } = variableWithMetadata;
return {
name: key,
description: metadata.description ?? '',
value: value ?? null,
isSensitive: metadata.isSensitive ?? false,
isEnvOnly: metadata.isEnvOnly ?? false,
type: metadata.type,
options: metadata.options,
source,
};
}
async getVersionInfo(): Promise<VersionInfo> { async getVersionInfo(): Promise<VersionInfo> {
const currentVersion = this.twentyConfigService.get('APP_VERSION'); const currentVersion = this.twentyConfigService.get('APP_VERSION');

View File

@ -1,4 +1,19 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { ConfigVariableValue } from 'twenty-shared/types';
import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
registerEnumType(ConfigSource, {
name: 'ConfigSource',
});
registerEnumType(ConfigVariableType, {
name: 'ConfigVariableType',
});
@ObjectType() @ObjectType()
export class ConfigVariable { export class ConfigVariable {
@ -8,9 +23,21 @@ export class ConfigVariable {
@Field() @Field()
description: string; description: string;
@Field() @Field(() => GraphQLJSON, { nullable: true })
value: string; value: ConfigVariableValue;
@Field() @Field()
isSensitive: boolean; isSensitive: boolean;
@Field()
source: ConfigSource;
@Field()
isEnvOnly: boolean;
@Field(() => ConfigVariableType)
type: ConfigVariableType;
@Field(() => GraphQLJSON, { nullable: true })
options?: ConfigVariableOptions;
} }

View File

@ -142,4 +142,7 @@ export class ClientConfig {
@Field(() => Boolean) @Field(() => Boolean)
isGoogleCalendarEnabled: boolean; isGoogleCalendarEnabled: boolean;
@Field(() => Boolean)
isConfigVariablesInDbEnabled: boolean;
} }

View File

@ -97,6 +97,9 @@ export class ClientConfigResolver {
isGoogleCalendarEnabled: this.twentyConfigService.get( isGoogleCalendarEnabled: this.twentyConfigService.get(
'CALENDAR_PROVIDER_GOOGLE_ENABLED', 'CALENDAR_PROVIDER_GOOGLE_ENABLED',
), ),
isConfigVariablesInDbEnabled: this.twentyConfigService.get(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
),
}; };
return Promise.resolve(clientConfig); return Promise.resolve(clientConfig);

View File

@ -1,4 +1,4 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { import {
ConfigCacheEntry, ConfigCacheEntry,
@ -8,7 +8,6 @@ import {
@Injectable() @Injectable()
export class ConfigCacheService implements OnModuleDestroy { export class ConfigCacheService implements OnModuleDestroy {
private readonly logger = new Logger(ConfigCacheService.name);
private readonly foundConfigValuesCache: Map< private readonly foundConfigValuesCache: Map<
ConfigKey, ConfigKey,
ConfigCacheEntry<ConfigKey> ConfigCacheEntry<ConfigKey>

View File

@ -33,13 +33,18 @@ import { IsDuration } from 'src/engine/core-modules/twenty-config/decorators/is-
import { IsOptionalOrEmptyString } from 'src/engine/core-modules/twenty-config/decorators/is-optional-or-empty-string.decorator'; import { IsOptionalOrEmptyString } from 'src/engine/core-modules/twenty-config/decorators/is-optional-or-empty-string.decorator';
import { IsStrictlyLowerThan } from 'src/engine/core-modules/twenty-config/decorators/is-strictly-lower-than.decorator'; import { IsStrictlyLowerThan } from 'src/engine/core-modules/twenty-config/decorators/is-strictly-lower-than.decorator';
import { IsTwentySemVer } from 'src/engine/core-modules/twenty-config/decorators/is-twenty-semver.decorator'; import { IsTwentySemVer } from 'src/engine/core-modules/twenty-config/decorators/is-twenty-semver.decorator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
export class ConfigVariables { export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Enable or disable password authentication for users', description: 'Enable or disable password authentication for users',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
AUTH_PASSWORD_ENABLED = true; AUTH_PASSWORD_ENABLED = true;
@ -48,7 +53,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: description:
'Prefills tim@apple.dev in the login form, used in local development for quicker sign-in', 'Prefills tim@apple.dev in the login form, used in local development for quicker sign-in',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
@ValidateIf((env) => env.AUTH_PASSWORD_ENABLED) @ValidateIf((env) => env.AUTH_PASSWORD_ENABLED)
@ -57,7 +62,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Require email verification for user accounts', description: 'Require email verification for user accounts',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
IS_EMAIL_VERIFICATION_REQUIRED = false; IS_EMAIL_VERIFICATION_REQUIRED = false;
@ -65,7 +70,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the email verification token is valid', description: 'Duration for which the email verification token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsDuration() @IsDuration()
@IsOptional() @IsOptional()
@ -74,7 +79,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the password reset token is valid', description: 'Duration for which the password reset token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsDuration() @IsDuration()
@IsOptional() @IsOptional()
@ -83,30 +88,31 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth, group: ConfigVariablesGroup.GoogleAuth,
description: 'Enable or disable the Google Calendar integration', description: 'Enable or disable the Google Calendar integration',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
CALENDAR_PROVIDER_GOOGLE_ENABLED = false; CALENDAR_PROVIDER_GOOGLE_ENABLED = false;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth, group: ConfigVariablesGroup.GoogleAuth,
description: 'Callback URL for Google Auth APIs', description: 'Callback URL for Google Auth APIs',
type: 'string', type: ConfigVariableType.STRING,
isSensitive: false,
}) })
AUTH_GOOGLE_APIS_CALLBACK_URL: string; AUTH_GOOGLE_APIS_CALLBACK_URL: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth, group: ConfigVariablesGroup.GoogleAuth,
description: 'Enable or disable Google Single Sign-On (SSO)', description: 'Enable or disable Google Single Sign-On (SSO)',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
AUTH_GOOGLE_ENABLED = false; AUTH_GOOGLE_ENABLED = false;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth, group: ConfigVariablesGroup.GoogleAuth,
isSensitive: true, isSensitive: false,
description: 'Client ID for Google authentication', description: 'Client ID for Google authentication',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CLIENT_ID: string; AUTH_GOOGLE_CLIENT_ID: string;
@ -115,16 +121,16 @@ export class ConfigVariables {
group: ConfigVariablesGroup.GoogleAuth, group: ConfigVariablesGroup.GoogleAuth,
isSensitive: true, isSensitive: true,
description: 'Client secret for Google authentication', description: 'Client secret for Google authentication',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CLIENT_SECRET: string; AUTH_GOOGLE_CLIENT_SECRET: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth, group: ConfigVariablesGroup.GoogleAuth,
isSensitive: true, isSensitive: false,
description: 'Callback URL for Google authentication', description: 'Callback URL for Google authentication',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsUrl({ require_tld: false, require_protocol: true }) @IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
@ -133,23 +139,23 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth, group: ConfigVariablesGroup.GoogleAuth,
description: 'Enable or disable the Gmail messaging integration', description: 'Enable or disable the Gmail messaging integration',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
MESSAGING_PROVIDER_GMAIL_ENABLED = false; MESSAGING_PROVIDER_GMAIL_ENABLED = false;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,
description: 'Enable or disable Microsoft authentication', description: 'Enable or disable Microsoft authentication',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
AUTH_MICROSOFT_ENABLED = false; AUTH_MICROSOFT_ENABLED = false;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true, isSensitive: false,
description: 'Client ID for Microsoft authentication', description: 'Client ID for Microsoft authentication',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CLIENT_ID: string; AUTH_MICROSOFT_CLIENT_ID: string;
@ -158,16 +164,16 @@ export class ConfigVariables {
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true, isSensitive: true,
description: 'Client secret for Microsoft authentication', description: 'Client secret for Microsoft authentication',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CLIENT_SECRET: string; AUTH_MICROSOFT_CLIENT_SECRET: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true, isSensitive: false,
description: 'Callback URL for Microsoft authentication', description: 'Callback URL for Microsoft authentication',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsUrl({ require_tld: false, require_protocol: true }) @IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
@ -175,9 +181,9 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true, isSensitive: false,
description: 'Callback URL for Microsoft APIs', description: 'Callback URL for Microsoft APIs',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsUrl({ require_tld: false, require_protocol: true }) @IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
@ -186,14 +192,14 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,
description: 'Enable or disable the Microsoft messaging integration', description: 'Enable or disable the Microsoft messaging integration',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
MESSAGING_PROVIDER_MICROSOFT_ENABLED = false; MESSAGING_PROVIDER_MICROSOFT_ENABLED = false;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth, group: ConfigVariablesGroup.MicrosoftAuth,
description: 'Enable or disable the Microsoft Calendar integration', description: 'Enable or disable the Microsoft Calendar integration',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
CALENDAR_PROVIDER_MICROSOFT_ENABLED = false; CALENDAR_PROVIDER_MICROSOFT_ENABLED = false;
@ -202,7 +208,7 @@ export class ConfigVariables {
isSensitive: true, isSensitive: true,
description: description:
'Legacy variable to be deprecated when all API Keys expire. Replaced by APP_KEY', 'Legacy variable to be deprecated when all API Keys expire. Replaced by APP_KEY',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
ACCESS_TOKEN_SECRET: string; ACCESS_TOKEN_SECRET: string;
@ -210,7 +216,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the access token is valid', description: 'Duration for which the access token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsDuration() @IsDuration()
@IsOptional() @IsOptional()
@ -219,7 +225,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the refresh token is valid', description: 'Duration for which the refresh token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
REFRESH_TOKEN_EXPIRES_IN = '60d'; REFRESH_TOKEN_EXPIRES_IN = '60d';
@ -227,7 +233,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Cooldown period for refreshing tokens', description: 'Cooldown period for refreshing tokens',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsDuration() @IsDuration()
@IsOptional() @IsOptional()
@ -236,7 +242,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the login token is valid', description: 'Duration for which the login token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsDuration() @IsDuration()
@IsOptional() @IsOptional()
@ -245,7 +251,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the file token is valid', description: 'Duration for which the file token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsDuration() @IsDuration()
@IsOptional() @IsOptional()
@ -254,7 +260,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the invitation token is valid', description: 'Duration for which the invitation token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsDuration() @IsDuration()
@IsOptional() @IsOptional()
@ -263,35 +269,35 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the short-term token is valid', description: 'Duration for which the short-term token is valid',
type: 'string', type: ConfigVariableType.STRING,
}) })
SHORT_TERM_TOKEN_EXPIRES_IN = '5m'; SHORT_TERM_TOKEN_EXPIRES_IN = '5m';
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'Email address used as the sender for outgoing emails', description: 'Email address used as the sender for outgoing emails',
type: 'string', type: ConfigVariableType.STRING,
}) })
EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com'; EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com';
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'Email address used for system notifications', description: 'Email address used for system notifications',
type: 'string', type: ConfigVariableType.STRING,
}) })
EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com'; EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com';
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'Name used in the From header for outgoing emails', description: 'Name used in the From header for outgoing emails',
type: 'string', type: ConfigVariableType.STRING,
}) })
EMAIL_FROM_NAME = 'Felix from Twenty'; EMAIL_FROM_NAME = 'Felix from Twenty';
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'Email driver to use for sending emails', description: 'Email driver to use for sending emails',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(EmailDriver), options: Object.values(EmailDriver),
}) })
EMAIL_DRIVER: EmailDriver = EmailDriver.Logger; EMAIL_DRIVER: EmailDriver = EmailDriver.Logger;
@ -299,14 +305,14 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'SMTP host for sending emails', description: 'SMTP host for sending emails',
type: 'string', type: ConfigVariableType.STRING,
}) })
EMAIL_SMTP_HOST: string; EMAIL_SMTP_HOST: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'Use unsecure connection for SMTP', description: 'Use unsecure connection for SMTP',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
EMAIL_SMTP_NO_TLS = false; EMAIL_SMTP_NO_TLS = false;
@ -314,7 +320,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'SMTP port for sending emails', description: 'SMTP port for sending emails',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
EMAIL_SMTP_PORT = 587; EMAIL_SMTP_PORT = 587;
@ -322,7 +328,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'SMTP user for authentication', description: 'SMTP user for authentication',
type: 'string', type: ConfigVariableType.STRING,
isSensitive: true,
}) })
EMAIL_SMTP_USER: string; EMAIL_SMTP_USER: string;
@ -330,14 +337,14 @@ export class ConfigVariables {
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
isSensitive: true, isSensitive: true,
description: 'SMTP password for authentication', description: 'SMTP password for authentication',
type: 'string', type: ConfigVariableType.STRING,
}) })
EMAIL_SMTP_PASSWORD: string; EMAIL_SMTP_PASSWORD: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig, group: ConfigVariablesGroup.StorageConfig,
description: 'Type of storage to use (local or S3)', description: 'Type of storage to use (local or S3)',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(StorageDriverType), options: Object.values(StorageDriverType),
}) })
@IsOptional() @IsOptional()
@ -346,7 +353,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig, group: ConfigVariablesGroup.StorageConfig,
description: 'Local path for storage when using local storage type', description: 'Local path for storage when using local storage type',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.Local) @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.Local)
STORAGE_LOCAL_PATH = '.local-storage'; STORAGE_LOCAL_PATH = '.local-storage';
@ -354,7 +361,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig, group: ConfigVariablesGroup.StorageConfig,
description: 'S3 region for storage when using S3 storage type', description: 'S3 region for storage when using S3 storage type',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsAWSRegion() @IsAWSRegion()
@ -363,7 +370,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig, group: ConfigVariablesGroup.StorageConfig,
description: 'S3 bucket name for storage when using S3 storage type', description: 'S3 bucket name for storage when using S3 storage type',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
STORAGE_S3_NAME: string; STORAGE_S3_NAME: string;
@ -371,7 +378,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig, group: ConfigVariablesGroup.StorageConfig,
description: 'S3 endpoint for storage when using S3 storage type', description: 'S3 endpoint for storage when using S3 storage type',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsOptional() @IsOptional()
@ -382,7 +389,7 @@ export class ConfigVariables {
isSensitive: true, isSensitive: true,
description: description:
'S3 access key ID for authentication when using S3 storage type', 'S3 access key ID for authentication when using S3 storage type',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsOptional() @IsOptional()
@ -393,7 +400,7 @@ export class ConfigVariables {
isSensitive: true, isSensitive: true,
description: description:
'S3 secret access key for authentication when using S3 storage type', 'S3 secret access key for authentication when using S3 storage type',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsOptional() @IsOptional()
@ -402,7 +409,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
description: 'Type of serverless execution (local or Lambda)', description: 'Type of serverless execution (local or Lambda)',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(ServerlessDriverType), options: Object.values(ServerlessDriverType),
}) })
@IsOptional() @IsOptional()
@ -411,7 +418,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
description: 'Throttle limit for serverless function execution', description: 'Throttle limit for serverless function execution',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
SERVERLESS_FUNCTION_EXEC_THROTTLE_LIMIT = 10; SERVERLESS_FUNCTION_EXEC_THROTTLE_LIMIT = 10;
@ -420,7 +427,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
description: 'Time-to-live for serverless function execution throttle', description: 'Time-to-live for serverless function execution throttle',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000; SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000;
@ -428,7 +435,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
description: 'Region for AWS Lambda functions', description: 'Region for AWS Lambda functions',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsAWSRegion() @IsAWSRegion()
@ -437,7 +444,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
description: 'IAM role for AWS Lambda functions', description: 'IAM role for AWS Lambda functions',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
SERVERLESS_LAMBDA_ROLE: string; SERVERLESS_LAMBDA_ROLE: string;
@ -445,7 +452,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
description: 'Role to assume when hosting lambdas in dedicated AWS account', description: 'Role to assume when hosting lambdas in dedicated AWS account',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsOptional() @IsOptional()
@ -455,7 +462,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
isSensitive: true, isSensitive: true,
description: 'Access key ID for AWS Lambda functions', description: 'Access key ID for AWS Lambda functions',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsOptional() @IsOptional()
@ -465,7 +472,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerlessConfig, group: ConfigVariablesGroup.ServerlessConfig,
isSensitive: true, isSensitive: true,
description: 'Secret access key for AWS Lambda functions', description: 'Secret access key for AWS Lambda functions',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsOptional() @IsOptional()
@ -474,7 +481,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.AnalyticsConfig, group: ConfigVariablesGroup.AnalyticsConfig,
description: 'Enable or disable analytics for telemetry', description: 'Enable or disable analytics for telemetry',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
ANALYTICS_ENABLED = false; ANALYTICS_ENABLED = false;
@ -482,7 +489,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.AnalyticsConfig, group: ConfigVariablesGroup.AnalyticsConfig,
description: 'Clickhouse host for analytics', description: 'Clickhouse host for analytics',
type: 'string', type: ConfigVariableType.STRING,
isSensitive: true,
}) })
@IsOptional() @IsOptional()
@IsUrl({ @IsUrl({
@ -495,7 +503,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Enable or disable telemetry logging', description: 'Enable or disable telemetry logging',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
TELEMETRY_ENABLED = true; TELEMETRY_ENABLED = true;
@ -503,7 +511,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
description: 'Enable or disable billing features', description: 'Enable or disable billing features',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
IS_BILLING_ENABLED = false; IS_BILLING_ENABLED = false;
@ -511,7 +519,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
description: 'Link required for billing plan', description: 'Link required for billing plan',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.IS_BILLING_ENABLED === true) @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_PLAN_REQUIRED_LINK: string; BILLING_PLAN_REQUIRED_LINK: string;
@ -519,7 +527,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
description: 'Duration of free trial with credit card in days', description: 'Duration of free trial with credit card in days',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsOptional() @IsOptional()
@ -529,7 +537,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
description: 'Duration of free trial without credit card in days', description: 'Duration of free trial without credit card in days',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsOptional() @IsOptional()
@ -539,7 +547,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of money in cents to trigger a billing threshold', description: 'Amount of money in cents to trigger a billing threshold',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true) @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
@ -548,7 +556,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial without credit card', description: 'Amount of credits for the free trial without credit card',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true) @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
@ -557,7 +565,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial with credit card', description: 'Amount of credits for the free trial with credit card',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true) @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
@ -567,7 +575,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
isSensitive: true, isSensitive: true,
description: 'Stripe API key for billing', description: 'Stripe API key for billing',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.IS_BILLING_ENABLED === true) @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_API_KEY: string; BILLING_STRIPE_API_KEY: string;
@ -576,7 +584,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.BillingConfig, group: ConfigVariablesGroup.BillingConfig,
isSensitive: true, isSensitive: true,
description: 'Stripe webhook secret for billing', description: 'Stripe webhook secret for billing',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.IS_BILLING_ENABLED === true) @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_WEBHOOK_SECRET: string; BILLING_STRIPE_WEBHOOK_SECRET: string;
@ -584,7 +592,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Url for the frontend application', description: 'Url for the frontend application',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsUrl({ require_tld: false, require_protocol: true }) @IsUrl({ require_tld: false, require_protocol: true })
@IsOptional() @IsOptional()
@ -594,7 +602,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: description:
'Default subdomain for the frontend when multi-workspace is enabled', 'Default subdomain for the frontend when multi-workspace is enabled',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED) @ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED)
DEFAULT_SUBDOMAIN = 'app'; DEFAULT_SUBDOMAIN = 'app';
@ -602,7 +610,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'ID for the Chrome extension', description: 'ID for the Chrome extension',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
CHROME_EXTENSION_ID: string; CHROME_EXTENSION_ID: string;
@ -610,7 +618,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Enable or disable buffering for logs before sending', description: 'Enable or disable buffering for logs before sending',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
LOGGER_IS_BUFFER_ENABLED = true; LOGGER_IS_BUFFER_ENABLED = true;
@ -618,7 +626,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Driver used for handling exceptions (Console or Sentry)', description: 'Driver used for handling exceptions (Console or Sentry)',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(ExceptionHandlerDriver), options: Object.values(ExceptionHandlerDriver),
}) })
@IsOptional() @IsOptional()
@ -628,8 +636,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured', description: 'Levels of logging to be captured',
type: 'array', type: ConfigVariableType.ARRAY,
options: ['log', 'error', 'warn'], options: ['log', 'error', 'warn', 'debug', 'verbose'],
}) })
@CastToLogLevelArray() @CastToLogLevelArray()
@IsOptional() @IsOptional()
@ -638,7 +646,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Metering, group: ConfigVariablesGroup.Metering,
description: 'Driver used for collect metrics (OpenTelemetry or Console)', description: 'Driver used for collect metrics (OpenTelemetry or Console)',
type: 'array', type: ConfigVariableType.ARRAY,
options: ['OpenTelemetry', 'Console'], options: ['OpenTelemetry', 'Console'],
}) })
@CastToMeterDriverArray() @CastToMeterDriverArray()
@ -648,7 +656,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Metering, group: ConfigVariablesGroup.Metering,
description: 'Endpoint URL for the OpenTelemetry collector', description: 'Endpoint URL for the OpenTelemetry collector',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
OTLP_COLLECTOR_ENDPOINT_URL: string; OTLP_COLLECTOR_ENDPOINT_URL: string;
@ -656,7 +664,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler, group: ConfigVariablesGroup.ExceptionHandler,
description: 'Driver used for logging (only console for now)', description: 'Driver used for logging (only console for now)',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(LoggerDriverType), options: Object.values(LoggerDriverType),
}) })
@IsOptional() @IsOptional()
@ -665,7 +673,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler, group: ConfigVariablesGroup.ExceptionHandler,
description: 'Data Source Name (DSN) for Sentry logging', description: 'Data Source Name (DSN) for Sentry logging',
type: 'string', type: ConfigVariableType.STRING,
isSensitive: true,
}) })
@ValidateIf( @ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
@ -675,7 +684,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler, group: ConfigVariablesGroup.ExceptionHandler,
description: 'Front-end DSN for Sentry logging', description: 'Front-end DSN for Sentry logging',
type: 'string', type: ConfigVariableType.STRING,
isSensitive: true,
}) })
@ValidateIf( @ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
@ -684,7 +694,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler, group: ConfigVariablesGroup.ExceptionHandler,
description: 'Environment name for Sentry logging', description: 'Environment name for Sentry logging',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf( @ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
@ -695,7 +705,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.SupportChatConfig, group: ConfigVariablesGroup.SupportChatConfig,
description: 'Driver used for support chat integration', description: 'Driver used for support chat integration',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(SupportDriver), options: Object.values(SupportDriver),
}) })
@IsOptional() @IsOptional()
@ -705,7 +715,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.SupportChatConfig, group: ConfigVariablesGroup.SupportChatConfig,
isSensitive: true, isSensitive: true,
description: 'Chat ID for the support front integration', description: 'Chat ID for the support front integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front) @ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
SUPPORT_FRONT_CHAT_ID: string; SUPPORT_FRONT_CHAT_ID: string;
@ -714,7 +724,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.SupportChatConfig, group: ConfigVariablesGroup.SupportChatConfig,
isSensitive: true, isSensitive: true,
description: 'HMAC key for the support front integration', description: 'HMAC key for the support front integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front) @ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
SUPPORT_FRONT_HMAC_KEY: string; SUPPORT_FRONT_HMAC_KEY: string;
@ -723,7 +733,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
isSensitive: true, isSensitive: true,
description: 'Database connection URL', description: 'Database connection URL',
type: 'string', type: ConfigVariableType.STRING,
isEnvOnly: true, isEnvOnly: true,
}) })
@IsDefined() @IsDefined()
@ -740,7 +750,7 @@ export class ConfigVariables {
description: description:
'Allow connections to a database with self-signed certificates', 'Allow connections to a database with self-signed certificates',
isEnvOnly: true, isEnvOnly: true,
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
PG_SSL_ALLOW_SELF_SIGNED = false; PG_SSL_ALLOW_SELF_SIGNED = false;
@ -749,7 +759,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Enable configuration variables to be stored in the database', description: 'Enable configuration variables to be stored in the database',
isEnvOnly: true, isEnvOnly: true,
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
IS_CONFIG_VARIABLES_IN_DB_ENABLED = false; IS_CONFIG_VARIABLES_IN_DB_ENABLED = false;
@ -757,7 +767,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration, group: ConfigVariablesGroup.TokensDuration,
description: 'Time-to-live for cache storage in seconds', description: 'Time-to-live for cache storage in seconds',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
CACHE_STORAGE_TTL: number = 3600 * 24 * 7; CACHE_STORAGE_TTL: number = 3600 * 24 * 7;
@ -767,7 +777,7 @@ export class ConfigVariables {
isSensitive: true, isSensitive: true,
description: 'URL for cache storage (e.g., Redis connection URL)', description: 'URL for cache storage (e.g., Redis connection URL)',
isEnvOnly: true, isEnvOnly: true,
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
@IsUrl({ @IsUrl({
@ -780,7 +790,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Node environment (development, production, etc.)', description: 'Node environment (development, production, etc.)',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(NodeEnvironment), options: Object.values(NodeEnvironment),
}) })
NODE_ENV: NodeEnvironment = NodeEnvironment.production; NODE_ENV: NodeEnvironment = NodeEnvironment.production;
@ -788,7 +798,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Port for the node server', description: 'Port for the node server',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsOptional() @IsOptional()
@ -797,7 +807,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Base URL for the server', description: 'Base URL for the server',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsUrl({ require_tld: false, require_protocol: true }) @IsUrl({ require_tld: false, require_protocol: true })
@IsOptional() @IsOptional()
@ -808,14 +818,14 @@ export class ConfigVariables {
isSensitive: true, isSensitive: true,
description: 'Secret key for the application', description: 'Secret key for the application',
isEnvOnly: true, isEnvOnly: true,
type: 'string', type: ConfigVariableType.STRING,
}) })
APP_SECRET: string; APP_SECRET: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting, group: ConfigVariablesGroup.RateLimiting,
description: 'Maximum number of records affected by mutations', description: 'Maximum number of records affected by mutations',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsOptional() @IsOptional()
@ -824,7 +834,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting, group: ConfigVariablesGroup.RateLimiting,
description: 'Time-to-live for API rate limiting in milliseconds', description: 'Time-to-live for API rate limiting in milliseconds',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
API_RATE_LIMITING_TTL = 100; API_RATE_LIMITING_TTL = 100;
@ -833,7 +843,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.RateLimiting, group: ConfigVariablesGroup.RateLimiting,
description: description:
'Maximum number of requests allowed in the rate limiting window', 'Maximum number of requests allowed in the rate limiting window',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
API_RATE_LIMITING_LIMIT = 500; API_RATE_LIMITING_LIMIT = 500;
@ -841,7 +851,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.SSL, group: ConfigVariablesGroup.SSL,
description: 'Path to the SSL key for enabling HTTPS in local development', description: 'Path to the SSL key for enabling HTTPS in local development',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
SSL_KEY_PATH: string; SSL_KEY_PATH: string;
@ -850,7 +860,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.SSL, group: ConfigVariablesGroup.SSL,
description: description:
'Path to the SSL certificate for enabling HTTPS in local development', 'Path to the SSL certificate for enabling HTTPS in local development',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
SSL_CERT_PATH: string; SSL_CERT_PATH: string;
@ -859,7 +869,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.CloudflareConfig, group: ConfigVariablesGroup.CloudflareConfig,
isSensitive: true, isSensitive: true,
description: 'API key for Cloudflare integration', description: 'API key for Cloudflare integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.CLOUDFLARE_ZONE_ID) @ValidateIf((env) => env.CLOUDFLARE_ZONE_ID)
CLOUDFLARE_API_KEY: string; CLOUDFLARE_API_KEY: string;
@ -867,7 +877,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.CloudflareConfig, group: ConfigVariablesGroup.CloudflareConfig,
description: 'Zone ID for Cloudflare integration', description: 'Zone ID for Cloudflare integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
@ValidateIf((env) => env.CLOUDFLARE_API_KEY) @ValidateIf((env) => env.CLOUDFLARE_API_KEY)
CLOUDFLARE_ZONE_ID: string; CLOUDFLARE_ZONE_ID: string;
@ -875,7 +885,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Random string to validate queries from Cloudflare', description: 'Random string to validate queries from Cloudflare',
type: 'string', type: ConfigVariableType.STRING,
isSensitive: true,
}) })
@IsOptional() @IsOptional()
CLOUDFLARE_WEBHOOK_SECRET: string; CLOUDFLARE_WEBHOOK_SECRET: string;
@ -883,7 +894,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM, group: ConfigVariablesGroup.LLM,
description: 'Driver for the LLM chat model', description: 'Driver for the LLM chat model',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(LLMChatModelDriver), options: Object.values(LLMChatModelDriver),
}) })
LLM_CHAT_MODEL_DRIVER: LLMChatModelDriver; LLM_CHAT_MODEL_DRIVER: LLMChatModelDriver;
@ -892,7 +903,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.LLM, group: ConfigVariablesGroup.LLM,
isSensitive: true, isSensitive: true,
description: 'API key for OpenAI integration', description: 'API key for OpenAI integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
OPENAI_API_KEY: string; OPENAI_API_KEY: string;
@ -900,21 +911,21 @@ export class ConfigVariables {
group: ConfigVariablesGroup.LLM, group: ConfigVariablesGroup.LLM,
isSensitive: true, isSensitive: true,
description: 'Secret key for Langfuse integration', description: 'Secret key for Langfuse integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
LANGFUSE_SECRET_KEY: string; LANGFUSE_SECRET_KEY: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM, group: ConfigVariablesGroup.LLM,
description: 'Public key for Langfuse integration', description: 'Public key for Langfuse integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
LANGFUSE_PUBLIC_KEY: string; LANGFUSE_PUBLIC_KEY: string;
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM, group: ConfigVariablesGroup.LLM,
description: 'Driver for LLM tracing', description: 'Driver for LLM tracing',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(LLMTracingDriver), options: Object.values(LLMTracingDriver),
}) })
LLM_TRACING_DRIVER: LLMTracingDriver = LLMTracingDriver.Console; LLM_TRACING_DRIVER: LLMTracingDriver = LLMTracingDriver.Console;
@ -922,7 +933,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Enable or disable multi-workspace support', description: 'Enable or disable multi-workspace support',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
IS_MULTIWORKSPACE_ENABLED = false; IS_MULTIWORKSPACE_ENABLED = false;
@ -931,7 +942,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: description:
'Number of inactive days before sending a deletion warning for workspaces. Used in the workspace deletion cron job to determine when to send warning emails.', 'Number of inactive days before sending a deletion warning for workspaces. Used in the workspace deletion cron job to determine when to send warning emails.',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION', { @IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION', {
@ -943,7 +954,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Number of inactive days before soft deleting workspaces', description: 'Number of inactive days before soft deleting workspaces',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', { @IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', {
@ -955,7 +966,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Number of inactive days before deleting workspaces', description: 'Number of inactive days before deleting workspaces',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 21; WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 21;
@ -964,7 +975,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: description:
'Maximum number of workspaces that can be deleted in a single execution', 'Maximum number of workspaces that can be deleted in a single execution',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0) @ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0)
@ -973,7 +984,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting, group: ConfigVariablesGroup.RateLimiting,
description: 'Throttle limit for workflow execution', description: 'Throttle limit for workflow execution',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
WORKFLOW_EXEC_THROTTLE_LIMIT = 500; WORKFLOW_EXEC_THROTTLE_LIMIT = 500;
@ -981,7 +992,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting, group: ConfigVariablesGroup.RateLimiting,
description: 'Time-to-live for workflow execution throttle in milliseconds', description: 'Time-to-live for workflow execution throttle in milliseconds',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
WORKFLOW_EXEC_THROTTLE_TTL = 1000; WORKFLOW_EXEC_THROTTLE_TTL = 1000;
@ -989,7 +1000,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.CaptchaConfig, group: ConfigVariablesGroup.CaptchaConfig,
description: 'Driver for captcha integration', description: 'Driver for captcha integration',
type: 'enum', type: ConfigVariableType.ENUM,
options: Object.values(CaptchaDriverType), options: Object.values(CaptchaDriverType),
}) })
@IsOptional() @IsOptional()
@ -999,7 +1010,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.CaptchaConfig, group: ConfigVariablesGroup.CaptchaConfig,
isSensitive: true, isSensitive: true,
description: 'Site key for captcha integration', description: 'Site key for captcha integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
CAPTCHA_SITE_KEY?: string; CAPTCHA_SITE_KEY?: string;
@ -1008,7 +1019,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.CaptchaConfig, group: ConfigVariablesGroup.CaptchaConfig,
isSensitive: true, isSensitive: true,
description: 'Secret key for captcha integration', description: 'Secret key for captcha integration',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
CAPTCHA_SECRET_KEY?: string; CAPTCHA_SECRET_KEY?: string;
@ -1017,7 +1028,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
isSensitive: true, isSensitive: true,
description: 'License key for the Enterprise version', description: 'License key for the Enterprise version',
type: 'string', type: ConfigVariableType.STRING,
}) })
@IsOptional() @IsOptional()
ENTERPRISE_KEY: string; ENTERPRISE_KEY: string;
@ -1025,7 +1036,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Health monitoring time window in minutes', description: 'Health monitoring time window in minutes',
type: 'number', type: ConfigVariableType.NUMBER,
}) })
@CastToPositiveNumber() @CastToPositiveNumber()
@IsOptional() @IsOptional()
@ -1034,7 +1045,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Enable or disable the attachment preview feature', description: 'Enable or disable the attachment preview feature',
type: 'boolean', type: ConfigVariableType.BOOLEAN,
}) })
@IsOptional() @IsOptional()
IS_ATTACHMENT_PREVIEW_ENABLED = true; IS_ATTACHMENT_PREVIEW_ENABLED = true;
@ -1042,7 +1053,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({ @ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Twenty server version', description: 'Twenty server version',
type: 'string', type: ConfigVariableType.STRING,
isEnvOnly: true,
}) })
@IsOptionalOrEmptyString() @IsOptionalOrEmptyString()
@IsTwentySemVer() @IsTwentySemVer()
@ -1076,7 +1088,10 @@ export const validate = (config: Record<string, unknown>): ConfigVariables => {
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
logValidatonErrors(validationErrors, 'error'); logValidatonErrors(validationErrors, 'error');
throw new Error('Config variables validation failed'); throw new ConfigVariableException(
'Config variables validation failed',
ConfigVariableExceptionCode.VALIDATION_FAILED,
);
} }
return validatedConfig; return validatedConfig;

View File

@ -4,11 +4,11 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants'; import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service'; import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
import { TypedReflect } from 'src/utils/typed-reflect'; import { TypedReflect } from 'src/utils/typed-reflect';
// Mock configTransformers for type validation tests
jest.mock( jest.mock(
'src/engine/core-modules/twenty-config/utils/config-transformers.util', 'src/engine/core-modules/twenty-config/utils/config-transformers.util',
() => { () => {
@ -19,7 +19,6 @@ jest.mock(
return { return {
configTransformers: { configTransformers: {
...originalModule.configTransformers, ...originalModule.configTransformers,
// These mocked versions can be overridden in specific tests
_mockedBoolean: jest.fn(), _mockedBoolean: jest.fn(),
_mockedNumber: jest.fn(), _mockedNumber: jest.fn(),
_mockedString: jest.fn(), _mockedString: jest.fn(),
@ -56,7 +55,7 @@ describe('ConfigValueConverterService', () => {
// Mock the metadata // Mock the metadata
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
AUTH_PASSWORD_ENABLED: { AUTH_PASSWORD_ENABLED: {
type: 'boolean', type: ConfigVariableType.BOOLEAN,
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Enable or disable password authentication for users', description: 'Enable or disable password authentication for users',
}, },
@ -116,7 +115,7 @@ describe('ConfigValueConverterService', () => {
it('should convert string to number based on metadata', () => { it('should convert string to number based on metadata', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
NODE_PORT: { NODE_PORT: {
type: 'number', type: ConfigVariableType.NUMBER,
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Port for the node server', description: 'Port for the node server',
}, },
@ -146,7 +145,7 @@ describe('ConfigValueConverterService', () => {
it('should convert string to array based on metadata', () => { it('should convert string to array based on metadata', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: { LOG_LEVELS: {
type: 'array', type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured', description: 'Levels of logging to be captured',
}, },
@ -161,7 +160,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: { LOG_LEVELS: {
type: 'array', type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured', description: 'Levels of logging to be captured',
}, },
@ -188,7 +187,7 @@ describe('ConfigValueConverterService', () => {
it('should handle various input types', () => { it('should handle various input types', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
AUTH_PASSWORD_ENABLED: { AUTH_PASSWORD_ENABLED: {
type: 'boolean', type: ConfigVariableType.BOOLEAN,
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Enable or disable password authentication for users', description: 'Enable or disable password authentication for users',
}, },
@ -202,7 +201,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
NODE_PORT: { NODE_PORT: {
type: 'number', type: ConfigVariableType.NUMBER,
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Port for the node server', description: 'Port for the node server',
}, },
@ -216,7 +215,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: { LOG_LEVELS: {
type: 'array', type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured', description: 'Levels of logging to be captured',
}, },
@ -259,7 +258,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if boolean converter returns non-boolean', () => { it('should throw error if boolean converter returns non-boolean', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
AUTH_PASSWORD_ENABLED: { AUTH_PASSWORD_ENABLED: {
type: 'boolean', type: ConfigVariableType.BOOLEAN,
group: ConfigVariablesGroup.Other, group: ConfigVariablesGroup.Other,
description: 'Test boolean', description: 'Test boolean',
}, },
@ -284,7 +283,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if number converter returns non-number', () => { it('should throw error if number converter returns non-number', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
NODE_PORT: { NODE_PORT: {
type: 'number', type: ConfigVariableType.NUMBER,
group: ConfigVariablesGroup.ServerConfig, group: ConfigVariablesGroup.ServerConfig,
description: 'Test number', description: 'Test number',
}, },
@ -309,7 +308,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if string converter returns non-string', () => { it('should throw error if string converter returns non-string', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
EMAIL_FROM_ADDRESS: { EMAIL_FROM_ADDRESS: {
type: 'string', type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.EmailSettings, group: ConfigVariablesGroup.EmailSettings,
description: 'Test string', description: 'Test string',
}, },
@ -332,7 +331,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if array conversion produces non-array', () => { it('should throw error if array conversion produces non-array', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: { LOG_LEVELS: {
type: 'array', type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Test array', description: 'Test array',
}, },
@ -358,7 +357,7 @@ describe('ConfigValueConverterService', () => {
it('should handle array with option validation', () => { it('should handle array with option validation', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: { LOG_LEVELS: {
type: 'array', type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Test array with options', description: 'Test array with options',
options: ['log', 'error', 'warn', 'debug'], options: ['log', 'error', 'warn', 'debug'],
@ -374,7 +373,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: { LOG_LEVELS: {
type: 'array', type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Test array with options', description: 'Test array with options',
options: ['log', 'error', 'warn', 'debug'], options: ['log', 'error', 'warn', 'debug'],
@ -392,7 +391,7 @@ describe('ConfigValueConverterService', () => {
it('should properly handle enum with options', () => { it('should properly handle enum with options', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVEL: { LOG_LEVEL: {
type: 'enum', type: ConfigVariableType.ENUM,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Test enum', description: 'Test enum',
options: ['log', 'error', 'warn', 'debug'], options: ['log', 'error', 'warn', 'debug'],
@ -408,7 +407,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({ jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVEL: { LOG_LEVEL: {
type: 'enum', type: ConfigVariableType.ENUM,
group: ConfigVariablesGroup.Logging, group: ConfigVariablesGroup.Logging,
description: 'Test enum', description: 'Test enum',
options: ['log', 'error', 'warn', 'debug'], options: ['log', 'error', 'warn', 'debug'],

View File

@ -3,8 +3,8 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants'; import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; import { ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type'; import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/types/config-variable-type.type';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
import { TypedReflect } from 'src/utils/typed-reflect'; import { TypedReflect } from 'src/utils/typed-reflect';
@ -31,7 +31,7 @@ export class ConfigValueConverterService {
try { try {
switch (configType) { switch (configType) {
case 'boolean': { case ConfigVariableType.BOOLEAN: {
const result = configTransformers.boolean(dbValue); const result = configTransformers.boolean(dbValue);
if (result !== undefined && typeof result !== 'boolean') { if (result !== undefined && typeof result !== 'boolean') {
@ -43,7 +43,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T]; return result as ConfigVariables[T];
} }
case 'number': { case ConfigVariableType.NUMBER: {
const result = configTransformers.number(dbValue); const result = configTransformers.number(dbValue);
if (result !== undefined && typeof result !== 'number') { if (result !== undefined && typeof result !== 'number') {
@ -55,7 +55,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T]; return result as ConfigVariables[T];
} }
case 'string': { case ConfigVariableType.STRING: {
const result = configTransformers.string(dbValue); const result = configTransformers.string(dbValue);
if (result !== undefined && typeof result !== 'string') { if (result !== undefined && typeof result !== 'string') {
@ -67,7 +67,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T]; return result as ConfigVariables[T];
} }
case 'array': { case ConfigVariableType.ARRAY: {
const result = this.convertToArray(dbValue, options); const result = this.convertToArray(dbValue, options);
if (result !== undefined && !Array.isArray(result)) { if (result !== undefined && !Array.isArray(result)) {
@ -79,7 +79,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T]; return result as ConfigVariables[T];
} }
case 'enum': { case ConfigVariableType.ENUM: {
const result = this.convertToEnum(dbValue, options); const result = this.convertToEnum(dbValue, options);
return result as ConfigVariables[T]; return result as ConfigVariables[T];
@ -204,10 +204,10 @@ export class ConfigValueConverterService {
): ConfigVariableType { ): ConfigVariableType {
const defaultValue = this.configVariables[key]; const defaultValue = this.configVariables[key];
if (typeof defaultValue === 'boolean') return 'boolean'; if (typeof defaultValue === 'boolean') return ConfigVariableType.BOOLEAN;
if (typeof defaultValue === 'number') return 'number'; if (typeof defaultValue === 'number') return ConfigVariableType.NUMBER;
if (Array.isArray(defaultValue)) return 'array'; if (Array.isArray(defaultValue)) return ConfigVariableType.ARRAY;
return 'string'; return ConfigVariableType.STRING;
} }
} }

View File

@ -4,9 +4,9 @@ import {
ValidationOptions, ValidationOptions,
} from 'class-validator'; } from 'class-validator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type'; import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/types/config-variable-type.type';
import { applyBasicValidators } from 'src/engine/core-modules/twenty-config/utils/apply-basic-validators.util'; import { applyBasicValidators } from 'src/engine/core-modules/twenty-config/utils/apply-basic-validators.util';
import { TypedReflect } from 'src/utils/typed-reflect'; import { TypedReflect } from 'src/utils/typed-reflect';
@ -15,7 +15,7 @@ export interface ConfigVariablesMetadataOptions {
description: string; description: string;
isSensitive?: boolean; isSensitive?: boolean;
isEnvOnly?: boolean; isEnvOnly?: boolean;
type?: ConfigVariableType; type: ConfigVariableType;
options?: ConfigVariableOptions; options?: ConfigVariableOptions;
} }
@ -51,14 +51,12 @@ export function ConfigVariablesMetadata(
IsOptional()(target, propertyKey); IsOptional()(target, propertyKey);
} }
if (options.type) { applyBasicValidators(
applyBasicValidators( options.type,
options.type, target,
target, propertyKey.toString(),
propertyKey.toString(), options.options,
options.options, );
);
}
registerDecorator({ registerDecorator({
name: propertyKey.toString(), name: propertyKey.toString(),

View File

@ -19,12 +19,10 @@ const CONFIG_ENV_ONLY_KEY = 'ENV_ONLY_VAR';
const CONFIG_PORT_KEY = 'NODE_PORT'; const CONFIG_PORT_KEY = 'NODE_PORT';
class TestDatabaseConfigDriver extends DatabaseConfigDriver { class TestDatabaseConfigDriver extends DatabaseConfigDriver {
// Expose the protected/private property for testing
public get testAllPossibleConfigKeys(): Array<keyof ConfigVariables> { public get testAllPossibleConfigKeys(): Array<keyof ConfigVariables> {
return this['allPossibleConfigKeys']; return this['allPossibleConfigKeys'];
} }
// Override Object.keys usage in constructor with our test keys
constructor( constructor(
configCache: ConfigCacheService, configCache: ConfigCacheService,
configStorage: ConfigStorageService, configStorage: ConfigStorageService,
@ -213,51 +211,6 @@ describe('DatabaseConfigDriver', () => {
}); });
}); });
describe('fetchAndCacheConfigVariable', () => {
it('should refresh config variable from storage', async () => {
const value = true;
jest.spyOn(configStorage, 'get').mockResolvedValue(value);
await driver.fetchAndCacheConfigVariable(CONFIG_PASSWORD_KEY);
expect(configStorage.get).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY);
expect(configCache.set).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY, value);
});
it('should mark key as missing when value is undefined', async () => {
jest.spyOn(configStorage, 'get').mockResolvedValue(undefined);
await driver.fetchAndCacheConfigVariable(CONFIG_PASSWORD_KEY);
expect(configStorage.get).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY);
expect(configCache.markKeyAsMissing).toHaveBeenCalledWith(
CONFIG_PASSWORD_KEY,
);
expect(configCache.set).not.toHaveBeenCalled();
});
it('should mark key as missing when storage fetch fails', async () => {
const error = new Error('Storage error');
jest.spyOn(configStorage, 'get').mockRejectedValue(error);
const loggerSpy = jest
.spyOn(driver['logger'], 'error')
.mockImplementation();
await driver.fetchAndCacheConfigVariable(CONFIG_PASSWORD_KEY);
expect(configStorage.get).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY);
expect(configCache.markKeyAsMissing).toHaveBeenCalledWith(
CONFIG_PASSWORD_KEY,
);
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch config'),
error,
);
});
});
describe('cache operations', () => { describe('cache operations', () => {
it('should return cache info', () => { it('should return cache info', () => {
const cacheInfo = { const cacheInfo = {

View File

@ -56,6 +56,18 @@ export class DatabaseConfigDriver
return this.configCache.get(key); return this.configCache.get(key);
} }
async set<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
): Promise<void> {
if (isEnvOnlyConfigVar(key)) {
throw new Error(`Cannot set environment-only variable: ${key as string}`);
}
await this.configStorage.set(key, value);
this.configCache.set(key, value);
}
async update<T extends keyof ConfigVariables>( async update<T extends keyof ConfigVariables>(
key: T, key: T,
value: ConfigVariables[T], value: ConfigVariables[T],
@ -66,43 +78,8 @@ export class DatabaseConfigDriver
); );
} }
try { await this.configStorage.set(key, value);
await this.configStorage.set(key, value); this.configCache.set(key, value);
this.configCache.set(key, value);
this.logger.debug(
`[UPDATE] Config variable ${key as string} updated successfully`,
);
} catch (error) {
this.logger.error(
`[UPDATE] Failed to update config variable ${key as string}`,
error,
);
throw error;
}
}
async fetchAndCacheConfigVariable(key: keyof ConfigVariables): Promise<void> {
try {
const value = await this.configStorage.get(key);
if (value !== undefined) {
this.configCache.set(key, value);
this.logger.debug(
`[FETCH] Config variable ${key as string} loaded from database`,
);
} else {
this.configCache.markKeyAsMissing(key);
this.logger.debug(
`[FETCH] Config variable ${key as string} not found in database, marked as missing`,
);
}
} catch (error) {
this.logger.error(
`[FETCH] Failed to fetch config variable ${key as string} from database`,
error,
);
this.configCache.markKeyAsMissing(key);
}
} }
getCacheInfo(): { getCacheInfo(): {
@ -114,39 +91,29 @@ export class DatabaseConfigDriver
} }
private async loadAllConfigVarsFromDb(): Promise<number> { private async loadAllConfigVarsFromDb(): Promise<number> {
try { const configVars = await this.configStorage.loadAll();
this.logger.debug('[LOAD] Fetching all config variables from database');
const configVars = await this.configStorage.loadAll();
this.logger.debug( for (const [key, value] of configVars.entries()) {
`[LOAD] Processing ${this.allPossibleConfigKeys.length} possible config variables`, this.configCache.set(key, value);
);
for (const [key, value] of configVars.entries()) {
this.configCache.set(key, value);
}
for (const key of this.allPossibleConfigKeys) {
if (!configVars.has(key)) {
this.configCache.markKeyAsMissing(key);
}
}
const missingKeysCount =
this.allPossibleConfigKeys.length - configVars.size;
this.logger.debug(
`[LOAD] Cached ${configVars.size} config variables, marked ${missingKeysCount} keys as missing`,
);
return configVars.size;
} catch (error) {
this.logger.error(
'[LOAD] Failed to load config variables from database',
error,
);
throw error;
} }
for (const key of this.allPossibleConfigKeys) {
if (!configVars.has(key)) {
this.configCache.markKeyAsMissing(key);
}
}
return configVars.size;
}
async delete(key: keyof ConfigVariables): Promise<void> {
if (isEnvOnlyConfigVar(key)) {
throw new Error(
`Cannot delete environment-only variable: ${key as string}`,
);
}
await this.configStorage.delete(key);
this.configCache.markKeyAsMissing(key);
} }
/** /**
@ -157,16 +124,8 @@ export class DatabaseConfigDriver
@Cron(CONFIG_VARIABLES_REFRESH_CRON_INTERVAL) @Cron(CONFIG_VARIABLES_REFRESH_CRON_INTERVAL)
async refreshAllCache(): Promise<void> { async refreshAllCache(): Promise<void> {
try { try {
this.logger.debug(
'[REFRESH] Starting scheduled refresh of config variables',
);
const dbValues = await this.configStorage.loadAll(); const dbValues = await this.configStorage.loadAll();
this.logger.debug(
`[REFRESH] Processing ${this.allPossibleConfigKeys.length} possible config variables`,
);
for (const [key, value] of dbValues.entries()) { for (const [key, value] of dbValues.entries()) {
if (!isEnvOnlyConfigVar(key)) { if (!isEnvOnlyConfigVar(key)) {
this.configCache.set(key, value); this.configCache.set(key, value);
@ -178,16 +137,12 @@ export class DatabaseConfigDriver
this.configCache.markKeyAsMissing(key); this.configCache.markKeyAsMissing(key);
} }
} }
const missingKeysCount =
this.allPossibleConfigKeys.length - dbValues.size;
this.logger.log(
`[REFRESH] Config variables refreshed: ${dbValues.size} values updated, ${missingKeysCount} marked as missing`,
);
} catch (error) { } catch (error) {
this.logger.error('[REFRESH] Failed to refresh config variables', error);
// Error is caught and logged but not rethrown to prevent the cron job from crashing // Error is caught and logged but not rethrown to prevent the cron job from crashing
this.logger.error(
'Failed to refresh config variables from database',
error instanceof Error ? error.stack : error,
);
} }
} }
} }

View File

@ -8,6 +8,7 @@ import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-va
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants'; import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service'; import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver'; import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigStorageService } from 'src/engine/core-modules/twenty-config/storage/config-storage.service'; import { ConfigStorageService } from 'src/engine/core-modules/twenty-config/storage/config-storage.service';
@Module({}) @Module({})
@ -24,6 +25,7 @@ export class DatabaseConfigModule {
ConfigCacheService, ConfigCacheService,
ConfigStorageService, ConfigStorageService,
ConfigValueConverterService, ConfigValueConverterService,
EnvironmentConfigDriver,
{ {
provide: CONFIG_VARIABLES_INSTANCE_TOKEN, provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: new ConfigVariables(), useValue: new ConfigVariables(),

View File

@ -19,11 +19,6 @@ export interface DatabaseConfigDriverInterface {
value: ConfigVariables[T], value: ConfigVariables[T],
): Promise<void>; ): Promise<void>;
/**
* Fetch and cache a specific configuration from its source
*/
fetchAndCacheConfigVariable(key: keyof ConfigVariables): Promise<void>;
/** /**
* Refreshes all entries in the config cache * Refreshes all entries in the config cache
*/ */

View File

@ -0,0 +1,7 @@
export enum ConfigVariableType {
BOOLEAN = 'boolean',
NUMBER = 'number',
ARRAY = 'array',
STRING = 'string',
ENUM = 'enum',
}

View File

@ -0,0 +1,31 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
@Catch(ConfigVariableException)
export class ConfigVariableGraphqlApiExceptionFilter
implements ExceptionFilter
{
catch(exception: ConfigVariableException) {
switch (exception.code) {
case ConfigVariableExceptionCode.VARIABLE_NOT_FOUND:
throw new NotFoundError(exception.message);
case ConfigVariableExceptionCode.ENVIRONMENT_ONLY_VARIABLE:
throw new ForbiddenError(exception.message);
case ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED:
throw new UserInputError(exception.message);
case ConfigVariableExceptionCode.INTERNAL_ERROR:
default:
throw new InternalServerError(exception.message);
}
}
}

View File

@ -3,20 +3,31 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { DeleteResult, IsNull, Repository } from 'typeorm'; import { DeleteResult, IsNull, Repository } from 'typeorm';
import * as authUtils from 'src/engine/core-modules/auth/auth.util';
import { import {
KeyValuePair, KeyValuePair,
KeyValuePairType, KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service'; import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { ConfigStorageService } from 'src/engine/core-modules/twenty-config/storage/config-storage.service'; import { ConfigStorageService } from 'src/engine/core-modules/twenty-config/storage/config-storage.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TypedReflect } from 'src/utils/typed-reflect';
jest.mock('src/engine/core-modules/auth/auth.util', () => ({
encryptText: jest.fn((text) => `encrypted:${text}`),
decryptText: jest.fn((text) => text.replace('encrypted:', '')),
}));
describe('ConfigStorageService', () => { describe('ConfigStorageService', () => {
let service: ConfigStorageService; let service: ConfigStorageService;
let keyValuePairRepository: Repository<KeyValuePair>; let keyValuePairRepository: Repository<KeyValuePair>;
let configValueConverter: ConfigValueConverterService; let configValueConverter: ConfigValueConverterService;
let environmentConfigDriver: EnvironmentConfigDriver;
const createMockKeyValuePair = ( const createMockKeyValuePair = (
key: string, key: string,
@ -47,6 +58,12 @@ describe('ConfigStorageService', () => {
convertAppValueToDbValue: jest.fn(), convertAppValueToDbValue: jest.fn(),
}, },
}, },
{
provide: EnvironmentConfigDriver,
useValue: {
get: jest.fn().mockReturnValue('test-secret'),
},
},
ConfigVariables, ConfigVariables,
{ {
provide: getRepositoryToken(KeyValuePair, 'core'), provide: getRepositoryToken(KeyValuePair, 'core'),
@ -68,6 +85,9 @@ describe('ConfigStorageService', () => {
configValueConverter = module.get<ConfigValueConverterService>( configValueConverter = module.get<ConfigValueConverterService>(
ConfigValueConverterService, ConfigValueConverterService,
); );
environmentConfigDriver = module.get<EnvironmentConfigDriver>(
EnvironmentConfigDriver,
);
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -136,6 +156,188 @@ describe('ConfigStorageService', () => {
await expect(service.get(key)).rejects.toThrow('Conversion error'); await expect(service.get(key)).rejects.toThrow('Conversion error');
}); });
it('should decrypt sensitive string values', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const originalValue = 'sensitive-value';
const encryptedValue = 'encrypted:sensitive-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(encryptedValue);
const result = await service.get(key);
expect(result).toBe(originalValue);
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.decryptText).toHaveBeenCalledWith(
encryptedValue,
'test-secret',
);
});
it('should handle decryption errors gracefully', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const encryptedValue = 'encrypted-value';
const convertedValue = 'converted-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(convertedValue);
const result = await service.get(key);
expect(result).toBe(convertedValue);
});
it('should handle decryption failure in get() by returning original value', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const encryptedValue = 'encrypted:sensitive-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(encryptedValue);
// Mock decryption to throw an error
(authUtils.decryptText as jest.Mock).mockImplementationOnce(() => {
throw new Error('Decryption failed');
});
const result = await service.get(key);
expect(result).toBe(encryptedValue); // Should fall back to encrypted value
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
});
it('should skip decryption for non-string sensitive values', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = { someKey: 'someValue' };
const mockRecord = createMockKeyValuePair(
key as string,
JSON.stringify(value),
);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(value);
const result = await service.get(key);
expect(result).toBe(value);
expect(authUtils.decryptText).not.toHaveBeenCalled();
});
it('should handle decryption failure in loadAll() by skipping failed values', async () => {
const configVars: KeyValuePair[] = [
createMockKeyValuePair('SENSITIVE_CONFIG_1', 'encrypted:value1'),
createMockKeyValuePair('SENSITIVE_CONFIG_2', 'encrypted:value2'),
createMockKeyValuePair('NORMAL_CONFIG', 'normal-value'),
];
jest.spyOn(keyValuePairRepository, 'find').mockResolvedValue(configVars);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
SENSITIVE_CONFIG_1: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config 1',
},
SENSITIVE_CONFIG_2: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config 2',
},
NORMAL_CONFIG: {
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test normal config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockImplementation((value) => value);
// Mock decryption to fail for the second sensitive value
(authUtils.decryptText as jest.Mock)
.mockImplementationOnce((text) => text.replace('encrypted:', ''))
.mockImplementationOnce(() => {
throw new Error('Decryption failed');
});
const result = await service.loadAll();
expect(result.size).toBe(3);
expect(result.get('SENSITIVE_CONFIG_1' as keyof ConfigVariables)).toBe(
'value1',
);
expect(result.get('SENSITIVE_CONFIG_2' as keyof ConfigVariables)).toBe(
'encrypted:value2',
); // Original encrypted value
expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe(
'normal-value',
);
});
}); });
describe('set', () => { describe('set', () => {
@ -197,6 +399,77 @@ describe('ConfigStorageService', () => {
await expect(service.set(key, value)).rejects.toThrow('Conversion error'); await expect(service.set(key, value)).rejects.toThrow('Conversion error');
}); });
it('should encrypt sensitive string values', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = 'sensitive-value';
const convertedValue = 'sensitive-value';
const encryptedValue = 'encrypted:sensitive-value';
jest.spyOn(keyValuePairRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertAppValueToDbValue as jest.Mock
).mockReturnValue(convertedValue);
await service.set(key, value);
expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
key: key as string,
value: encryptedValue,
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.encryptText).toHaveBeenCalledWith(
convertedValue,
'test-secret',
);
});
it('should handle encryption errors gracefully', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = 'sensitive-value';
const convertedValue = 'sensitive-value';
jest.spyOn(keyValuePairRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertAppValueToDbValue as jest.Mock
).mockReturnValue(convertedValue);
// Mock encryption to throw an error
(authUtils.encryptText as jest.Mock).mockImplementationOnce(() => {
throw new Error('Encryption failed');
});
await service.set(key, value);
expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
key: key as string,
value: convertedValue, // Should fall back to unconverted value
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
});
}); });
describe('delete', () => { describe('delete', () => {
@ -315,6 +588,47 @@ describe('ConfigStorageService', () => {
).toHaveBeenCalledTimes(1); // Only called for non-null value ).toHaveBeenCalledTimes(1); // Only called for non-null value
}); });
}); });
it('should decrypt sensitive string values in loadAll', async () => {
const configVars: KeyValuePair[] = [
createMockKeyValuePair('SENSITIVE_CONFIG', 'encrypted:sensitive-value'),
createMockKeyValuePair('NORMAL_CONFIG', 'normal-value'),
];
jest.spyOn(keyValuePairRepository, 'find').mockResolvedValue(configVars);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
SENSITIVE_CONFIG: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
NORMAL_CONFIG: {
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test normal config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockImplementation((value) => value);
const result = await service.loadAll();
expect(result.size).toBe(2);
expect(result.get('SENSITIVE_CONFIG' as keyof ConfigVariables)).toBe(
'sensitive-value',
);
expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe(
'normal-value',
);
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.decryptText).toHaveBeenCalledWith(
'encrypted:sensitive-value',
'test-secret',
);
});
}); });
describe('Edge Cases and Additional Scenarios', () => { describe('Edge Cases and Additional Scenarios', () => {

View File

@ -3,12 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, IsNull, Repository } from 'typeorm'; import { FindOptionsWhere, IsNull, Repository } from 'typeorm';
import {
decryptText,
encryptText,
} from 'src/engine/core-modules/auth/auth.util';
import { import {
KeyValuePair, KeyValuePair,
KeyValuePairType, KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service'; import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { TypedReflect } from 'src/utils/typed-reflect';
import { ConfigStorageInterface } from './interfaces/config-storage.interface'; import { ConfigStorageInterface } from './interfaces/config-storage.interface';
@ -20,6 +27,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
@InjectRepository(KeyValuePair, 'core') @InjectRepository(KeyValuePair, 'core')
private readonly keyValuePairRepository: Repository<KeyValuePair>, private readonly keyValuePairRepository: Repository<KeyValuePair>,
private readonly configValueConverter: ConfigValueConverterService, private readonly configValueConverter: ConfigValueConverterService,
private readonly environmentConfigDriver: EnvironmentConfigDriver,
) {} ) {}
private getConfigVariableWhereClause( private getConfigVariableWhereClause(
@ -33,6 +41,67 @@ export class ConfigStorageService implements ConfigStorageInterface {
}; };
} }
private getAppSecret(): string {
return this.environmentConfigDriver.get('APP_SECRET');
}
private getConfigMetadata<T extends keyof ConfigVariables>(key: T) {
return TypedReflect.getMetadata('config-variables', ConfigVariables)?.[
key as string
];
}
private logAndRethrow(message: string, error: any): never {
this.logger.error(message, error);
throw error;
}
private async convertAndSecureValue<T extends keyof ConfigVariables>(
value: any,
key: T,
isDecrypt = false,
): Promise<any> {
try {
const convertedValue = isDecrypt
? this.configValueConverter.convertDbValueToAppValue(value, key)
: this.configValueConverter.convertAppValueToDbValue(value);
const metadata = this.getConfigMetadata(key);
const isSensitiveString =
metadata?.isSensitive &&
metadata.type === ConfigVariableType.STRING &&
typeof convertedValue === 'string';
if (!isSensitiveString) {
return convertedValue;
}
const appSecret = this.getAppSecret();
try {
return isDecrypt
? decryptText(convertedValue, appSecret)
: encryptText(convertedValue, appSecret);
} catch (error) {
this.logger.error(
`Failed to ${isDecrypt ? 'decrypt' : 'encrypt'} value for key ${
key as string
}`,
error,
);
return convertedValue;
}
} catch (error) {
this.logAndRethrow(
`Failed to convert value ${
isDecrypt ? 'from DB' : 'to DB'
} for key ${key as string}`,
error,
);
}
}
async get<T extends keyof ConfigVariables>( async get<T extends keyof ConfigVariables>(
key: T, key: T,
): Promise<ConfigVariables[T] | undefined> { ): Promise<ConfigVariables[T] | undefined> {
@ -45,25 +114,13 @@ export class ConfigStorageService implements ConfigStorageInterface {
return undefined; return undefined;
} }
try { this.logger.debug(
this.logger.debug( `Fetching config for ${key as string} in database: ${result?.value}`,
`Fetching config for ${key as string} in database: ${result?.value}`, );
);
return this.configValueConverter.convertDbValueToAppValue( return await this.convertAndSecureValue(result.value, key, true);
result.value,
key,
);
} catch (error) {
this.logger.error(
`Failed to convert value to app type for key ${key as string}`,
error,
);
throw error;
}
} catch (error) { } catch (error) {
this.logger.error(`Failed to get config for ${key as string}`, error); this.logAndRethrow(`Failed to get config for ${key as string}`, error);
throw error;
} }
} }
@ -72,18 +129,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
value: ConfigVariables[T], value: ConfigVariables[T],
): Promise<void> { ): Promise<void> {
try { try {
let processedValue; const dbValue = await this.convertAndSecureValue(value, key, false);
try {
processedValue =
this.configValueConverter.convertAppValueToDbValue(value);
} catch (error) {
this.logger.error(
`Failed to convert value to storage type for key ${key as string}`,
error,
);
throw error;
}
const existingRecord = await this.keyValuePairRepository.findOne({ const existingRecord = await this.keyValuePairRepository.findOne({
where: this.getConfigVariableWhereClause(key as string), where: this.getConfigVariableWhereClause(key as string),
@ -92,20 +138,19 @@ export class ConfigStorageService implements ConfigStorageInterface {
if (existingRecord) { if (existingRecord) {
await this.keyValuePairRepository.update( await this.keyValuePairRepository.update(
{ id: existingRecord.id }, { id: existingRecord.id },
{ value: processedValue }, { value: dbValue },
); );
} else { } else {
await this.keyValuePairRepository.insert({ await this.keyValuePairRepository.insert({
key: key as string, key: key as string,
value: processedValue, value: dbValue,
userId: null, userId: null,
workspaceId: null, workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE, type: KeyValuePairType.CONFIG_VARIABLE,
}); });
} }
} catch (error) { } catch (error) {
this.logger.error(`Failed to set config for ${key as string}`, error); this.logAndRethrow(`Failed to set config for ${key as string}`, error);
throw error;
} }
} }
@ -115,8 +160,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
this.getConfigVariableWhereClause(key as string), this.getConfigVariableWhereClause(key as string),
); );
} catch (error) { } catch (error) {
this.logger.error(`Failed to delete config for ${key as string}`, error); this.logAndRethrow(`Failed to delete config for ${key as string}`, error);
throw error;
} }
} }
@ -138,9 +182,10 @@ export class ConfigStorageService implements ConfigStorageInterface {
const key = configVar.key as keyof ConfigVariables; const key = configVar.key as keyof ConfigVariables;
try { try {
const value = this.configValueConverter.convertDbValueToAppValue( const value = await this.convertAndSecureValue(
configVar.value, configVar.value,
key, key,
true,
); );
if (value !== undefined) { if (value !== undefined) {
@ -148,7 +193,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
} }
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to convert value to app type for key ${key as string}`, `Failed to process config value for key ${key as string}`,
error, error,
); );
continue; continue;
@ -158,8 +203,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
return result; return result;
} catch (error) { } catch (error) {
this.logger.error('Failed to load all config variables', error); this.logAndRethrow('Failed to load all config variables', error);
throw error;
} }
} }
} }

View File

@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
export class ConfigVariableException extends CustomException {
constructor(message: string, code: ConfigVariableExceptionCode) {
super(message, code);
}
}
export enum ConfigVariableExceptionCode {
DATABASE_CONFIG_DISABLED = 'DATABASE_CONFIG_DISABLED',
ENVIRONMENT_ONLY_VARIABLE = 'ENVIRONMENT_ONLY_VARIABLE',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
VALIDATION_FAILED = 'VALIDATION_FAILED',
UNSUPPORTED_CONFIG_TYPE = 'UNSUPPORTED_CONFIG_TYPE',
METADATA_NOT_FOUND = 'METADATA_NOT_FOUND',
INTERNAL_ERROR = 'INTERNAL_ERROR',
}

View File

@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver'; import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum'; import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum';
@ -58,7 +59,6 @@ const mockConfigVarMetadata = {
}, },
}; };
// Setup with database driver
const setupTestModule = async (isDatabaseConfigEnabled = true) => { const setupTestModule = async (isDatabaseConfigEnabled = true) => {
const configServiceMock = { const configServiceMock = {
get: jest.fn().mockImplementation((key) => { get: jest.fn().mockImplementation((key) => {
@ -70,6 +70,13 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
}), }),
}; };
const mockConfigVariablesInstance = {
TEST_VAR: 'test value',
ENV_ONLY_VAR: 'env only value',
SENSITIVE_VAR: 'sensitive value',
NO_METADATA_KEY: 'value without metadata',
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
TwentyConfigService, TwentyConfigService,
@ -77,8 +84,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
provide: DatabaseConfigDriver, provide: DatabaseConfigDriver,
useValue: { useValue: {
get: jest.fn(), get: jest.fn(),
set: jest.fn(),
update: jest.fn(), update: jest.fn(),
getCacheInfo: jest.fn(), getCacheInfo: jest.fn(),
delete: jest.fn(),
}, },
}, },
{ {
@ -93,6 +102,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
provide: ConfigService, provide: ConfigService,
useValue: configServiceMock, useValue: configServiceMock,
}, },
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: mockConfigVariablesInstance,
},
], ],
}).compile(); }).compile();
@ -104,10 +117,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
EnvironmentConfigDriver, EnvironmentConfigDriver,
), ),
configService: module.get<ConfigService>(ConfigService), configService: module.get<ConfigService>(ConfigService),
configVariablesInstance: module.get(CONFIG_VARIABLES_INSTANCE_TOKEN),
}; };
}; };
// Setup without database driver
const setupTestModuleWithoutDb = async () => { const setupTestModuleWithoutDb = async () => {
const configServiceMock = { const configServiceMock = {
get: jest.fn().mockImplementation((key) => { get: jest.fn().mockImplementation((key) => {
@ -119,6 +132,13 @@ const setupTestModuleWithoutDb = async () => {
}), }),
}; };
const mockConfigVariablesInstance = {
TEST_VAR: 'test value',
ENV_ONLY_VAR: 'env only value',
SENSITIVE_VAR: 'sensitive value',
NO_METADATA_KEY: 'value without metadata',
};
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
TwentyConfigService, TwentyConfigService,
@ -134,6 +154,10 @@ const setupTestModuleWithoutDb = async () => {
provide: ConfigService, provide: ConfigService,
useValue: configServiceMock, useValue: configServiceMock,
}, },
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: mockConfigVariablesInstance,
},
], ],
}).compile(); }).compile();
@ -143,6 +167,7 @@ const setupTestModuleWithoutDb = async () => {
EnvironmentConfigDriver, EnvironmentConfigDriver,
), ),
configService: module.get<ConfigService>(ConfigService), configService: module.get<ConfigService>(ConfigService),
configVariablesInstance: module.get(CONFIG_VARIABLES_INSTANCE_TOKEN),
}; };
}; };
@ -278,6 +303,10 @@ describe('TwentyConfigService', () => {
}); });
describe('update', () => { describe('update', () => {
beforeEach(() => {
jest.spyOn(service, 'validateConfigVariableExists').mockReturnValue(true);
});
it('should throw error when database driver is not active', async () => { it('should throw error when database driver is not active', async () => {
setPrivateProps(service, { isDatabaseDriverActive: false }); setPrivateProps(service, { isDatabaseDriverActive: false });
@ -468,4 +497,51 @@ describe('TwentyConfigService', () => {
}); });
}); });
}); });
describe('validateConfigVariableExists', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should be called by set, update, and delete methods', async () => {
const validateSpy = jest
.spyOn(service, 'validateConfigVariableExists')
.mockReturnValue(true);
setPrivateProps(service, { isDatabaseDriverActive: true });
jest
.spyOn(service as any, 'validateNotEnvOnly')
.mockImplementation(() => {});
await service.set('TEST_VAR' as keyof ConfigVariables, 'test value');
await service.update(
'TEST_VAR' as keyof ConfigVariables,
'updated value',
);
await service.delete('TEST_VAR' as keyof ConfigVariables);
expect(validateSpy).toHaveBeenCalledTimes(3);
expect(validateSpy).toHaveBeenCalledWith('TEST_VAR');
});
it('should return true for valid config variables with metadata', () => {
jest.spyOn(service, 'validateConfigVariableExists').mockRestore();
jest
.spyOn(service as any, 'getMetadata')
.mockReturnValue(mockConfigVarMetadata.TEST_VAR);
expect(service.validateConfigVariableExists('TEST_VAR')).toBe(true);
});
it('should throw error when config variable does not exist', () => {
jest.spyOn(service, 'validateConfigVariableExists').mockRestore();
expect(() => {
service.validateConfigVariableExists('MISSING_KEY');
}).toThrow(
'Config variable "MISSING_KEY" does not exist in ConfigVariables',
);
});
});
}); });

View File

@ -1,14 +1,19 @@
import { Injectable, Logger, Optional } from '@nestjs/common'; import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { isString } from 'class-validator'; import { isString } from 'class-validator';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables'; import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { CONFIG_VARIABLES_MASKING_CONFIG } from 'src/engine/core-modules/twenty-config/constants/config-variables-masking-config'; import { CONFIG_VARIABLES_MASKING_CONFIG } from 'src/engine/core-modules/twenty-config/constants/config-variables-masking-config';
import { ConfigVariablesMetadataOptions } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; import { ConfigVariablesMetadataOptions } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator';
import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver'; import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver'; import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum'; import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum';
import { ConfigVariablesMaskingStrategies } from 'src/engine/core-modules/twenty-config/enums/config-variables-masking-strategies.enum'; import { ConfigVariablesMaskingStrategies } from 'src/engine/core-modules/twenty-config/enums/config-variables-masking-strategies.enum';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { configVariableMaskSensitiveData } from 'src/engine/core-modules/twenty-config/utils/config-variable-mask-sensitive-data.util'; import { configVariableMaskSensitiveData } from 'src/engine/core-modules/twenty-config/utils/config-variable-mask-sensitive-data.util';
import { isEnvOnlyConfigVar } from 'src/engine/core-modules/twenty-config/utils/is-env-only-config-var.util'; import { isEnvOnlyConfigVar } from 'src/engine/core-modules/twenty-config/utils/is-env-only-config-var.util';
import { TypedReflect } from 'src/utils/typed-reflect'; import { TypedReflect } from 'src/utils/typed-reflect';
@ -21,6 +26,8 @@ export class TwentyConfigService {
constructor( constructor(
private readonly environmentConfigDriver: EnvironmentConfigDriver, private readonly environmentConfigDriver: EnvironmentConfigDriver,
@Optional() private readonly databaseConfigDriver: DatabaseConfigDriver, @Optional() private readonly databaseConfigDriver: DatabaseConfigDriver,
@Inject(CONFIG_VARIABLES_INSTANCE_TOKEN)
private readonly configVariablesInstance: ConfigVariables,
) { ) {
const isConfigVariablesInDbEnabled = this.environmentConfigDriver.get( const isConfigVariablesInDbEnabled = this.environmentConfigDriver.get(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED', 'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
@ -59,49 +66,37 @@ export class TwentyConfigService {
if (cachedValueFromDb !== undefined) { if (cachedValueFromDb !== undefined) {
return cachedValueFromDb; return cachedValueFromDb;
} }
return this.environmentConfigDriver.get(key);
} }
return this.environmentConfigDriver.get(key); return this.environmentConfigDriver.get(key);
} }
async set<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
): Promise<void> {
this.validateDatabaseDriverActive('set');
this.validateNotEnvOnly(key, 'create');
this.validateConfigVariableExists(key as string);
await this.databaseConfigDriver.set(key, value);
}
async update<T extends keyof ConfigVariables>( async update<T extends keyof ConfigVariables>(
key: T, key: T,
value: ConfigVariables[T], value: ConfigVariables[T],
): Promise<void> { ): Promise<void> {
if (!this.isDatabaseDriverActive) { this.validateDatabaseDriverActive('update');
throw new Error( this.validateNotEnvOnly(key, 'update');
'Database configuration is disabled or unavailable, cannot update configuration', this.validateConfigVariableExists(key as string);
);
}
const metadata = await this.databaseConfigDriver.update(key, value);
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
const envMetadata = metadata[key];
if (envMetadata?.isEnvOnly) {
throw new Error(
`Cannot update environment-only variable: ${key as string}`,
);
}
try {
await this.databaseConfigDriver.update(key, value);
this.logger.debug(`Updated config variable: ${key as string}`);
} catch (error) {
this.logger.error(`Failed to update config for ${key as string}`, error);
throw error;
}
} }
getMetadata( getMetadata(
key: keyof ConfigVariables, key: keyof ConfigVariables,
): ConfigVariablesMetadataOptions | undefined { ): ConfigVariablesMetadataOptions | undefined {
const metadata = return this.getConfigMetadata()[key as string];
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
return metadata[key];
} }
getAll(): Record< getAll(): Record<
@ -121,44 +116,14 @@ export class TwentyConfigService {
} }
> = {}; > = {};
const configVars = new ConfigVariables(); const metadata = this.getConfigMetadata();
const metadata =
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
Object.entries(metadata).forEach(([key, envMetadata]) => { Object.entries(metadata).forEach(([key, envMetadata]) => {
let value = this.get(key as keyof ConfigVariables) ?? ''; const typedKey = key as keyof ConfigVariables;
let source = ConfigSource.ENVIRONMENT; let value = this.get(typedKey) ?? '';
const source = this.determineConfigSource(typedKey, value, envMetadata);
if (!this.isDatabaseDriverActive || envMetadata.isEnvOnly) { value = this.maskSensitiveValue(typedKey, value);
if (value === configVars[key as keyof ConfigVariables]) {
source = ConfigSource.DEFAULT;
}
} else {
const dbValue = value;
source =
dbValue !== configVars[key as keyof ConfigVariables]
? ConfigSource.DATABASE
: ConfigSource.DEFAULT;
}
if (isString(value) && key in CONFIG_VARIABLES_MASKING_CONFIG) {
const varMaskingConfig =
CONFIG_VARIABLES_MASKING_CONFIG[
key as keyof typeof CONFIG_VARIABLES_MASKING_CONFIG
];
const options =
varMaskingConfig.strategy ===
ConfigVariablesMaskingStrategies.LAST_N_CHARS
? { chars: varMaskingConfig.chars }
: undefined;
value = configVariableMaskSensitiveData(
value,
varMaskingConfig.strategy,
{ ...options, variableName: key },
);
}
result[key] = { result[key] = {
value, value,
@ -170,6 +135,29 @@ export class TwentyConfigService {
return result; return result;
} }
getVariableWithMetadata(key: keyof ConfigVariables): {
value: ConfigVariables[keyof ConfigVariables];
metadata: ConfigVariablesMetadataOptions;
source: ConfigSource;
} | null {
const metadata = this.getMetadata(key);
if (!metadata) {
return null;
}
let value = this.get(key) ?? '';
const source = this.determineConfigSource(key, value, metadata);
value = this.maskSensitiveValue(key, value);
return {
value,
metadata,
source,
};
}
getCacheInfo(): { getCacheInfo(): {
usingDatabaseDriver: boolean; usingDatabaseDriver: boolean;
cacheStats?: { cacheStats?: {
@ -191,4 +179,100 @@ export class TwentyConfigService {
return result; return result;
} }
async delete(key: keyof ConfigVariables): Promise<void> {
this.validateDatabaseDriverActive('delete');
this.validateConfigVariableExists(key as string);
await this.databaseConfigDriver.delete(key);
}
private getConfigMetadata(): Record<string, ConfigVariablesMetadataOptions> {
return TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
}
private validateDatabaseDriverActive(operation: string): void {
if (!this.isDatabaseDriverActive) {
throw new ConfigVariableException(
`Database configuration is disabled or unavailable, cannot ${operation} configuration`,
ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED,
);
}
}
private validateNotEnvOnly<T extends keyof ConfigVariables>(
key: T,
operation: string,
): void {
const metadata = this.getConfigMetadata();
const envMetadata = metadata[key as string];
if (envMetadata?.isEnvOnly) {
throw new ConfigVariableException(
`Cannot ${operation} environment-only variable: ${key as string}`,
ConfigVariableExceptionCode.ENVIRONMENT_ONLY_VARIABLE,
);
}
}
private determineConfigSource<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
metadata: ConfigVariablesMetadataOptions,
): ConfigSource {
const configVars = new ConfigVariables();
if (!this.isDatabaseDriverActive || metadata.isEnvOnly) {
return value === configVars[key]
? ConfigSource.DEFAULT
: ConfigSource.ENVIRONMENT;
}
const dbValue = this.databaseConfigDriver.get(key);
if (dbValue !== undefined) {
return ConfigSource.DATABASE;
}
return value === configVars[key]
? ConfigSource.DEFAULT
: ConfigSource.ENVIRONMENT;
}
private maskSensitiveValue<T extends keyof ConfigVariables>(
key: T,
value: any,
): any {
if (!isString(value) || !(key in CONFIG_VARIABLES_MASKING_CONFIG)) {
return value;
}
const varMaskingConfig =
CONFIG_VARIABLES_MASKING_CONFIG[
key as keyof typeof CONFIG_VARIABLES_MASKING_CONFIG
];
const options =
varMaskingConfig.strategy ===
ConfigVariablesMaskingStrategies.LAST_N_CHARS
? { chars: varMaskingConfig.chars }
: undefined;
return configVariableMaskSensitiveData(value, varMaskingConfig.strategy, {
...options,
variableName: key as string,
});
}
validateConfigVariableExists(key: string): boolean {
const metadata = this.getConfigMetadata();
const keyExists = key in metadata;
if (!keyExists) {
throw new ConfigVariableException(
`Config variable "${key}" does not exist in ConfigVariables`,
ConfigVariableExceptionCode.VARIABLE_NOT_FOUND,
);
}
return true;
}
} }

View File

@ -1,6 +0,0 @@
export type ConfigVariableType =
| 'boolean'
| 'number'
| 'array'
| 'string'
| 'enum';

View File

@ -7,6 +7,7 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { applyBasicValidators } from 'src/engine/core-modules/twenty-config/utils/apply-basic-validators.util'; import { applyBasicValidators } from 'src/engine/core-modules/twenty-config/utils/apply-basic-validators.util';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
@ -50,7 +51,11 @@ describe('applyBasicValidators', () => {
return jest.fn(); return jest.fn();
}); });
applyBasicValidators('boolean', mockTarget, mockPropertyKey); applyBasicValidators(
ConfigVariableType.BOOLEAN,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled(); expect(Transform).toHaveBeenCalled();
expect(IsBoolean).toHaveBeenCalled(); expect(IsBoolean).toHaveBeenCalled();
@ -81,7 +86,11 @@ describe('applyBasicValidators', () => {
return jest.fn(); return jest.fn();
}); });
applyBasicValidators('number', mockTarget, mockPropertyKey); applyBasicValidators(
ConfigVariableType.NUMBER,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled(); expect(Transform).toHaveBeenCalled();
expect(IsNumber).toHaveBeenCalled(); expect(IsNumber).toHaveBeenCalled();
@ -104,7 +113,11 @@ describe('applyBasicValidators', () => {
describe('string type', () => { describe('string type', () => {
it('should apply string validator', () => { it('should apply string validator', () => {
applyBasicValidators('string', mockTarget, mockPropertyKey); applyBasicValidators(
ConfigVariableType.STRING,
mockTarget,
mockPropertyKey,
);
expect(IsString).toHaveBeenCalled(); expect(IsString).toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled(); // String doesn't need a transform expect(Transform).not.toHaveBeenCalled(); // String doesn't need a transform
@ -115,7 +128,12 @@ describe('applyBasicValidators', () => {
it('should apply enum validator with string array options', () => { it('should apply enum validator with string array options', () => {
const enumOptions = ['option1', 'option2', 'option3']; const enumOptions = ['option1', 'option2', 'option3'];
applyBasicValidators('enum', mockTarget, mockPropertyKey, enumOptions); applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
enumOptions,
);
expect(IsEnum).toHaveBeenCalledWith(enumOptions); expect(IsEnum).toHaveBeenCalledWith(enumOptions);
expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform
@ -128,14 +146,23 @@ describe('applyBasicValidators', () => {
Option3 = 'value3', Option3 = 'value3',
} }
applyBasicValidators('enum', mockTarget, mockPropertyKey, TestEnum); applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
TestEnum,
);
expect(IsEnum).toHaveBeenCalledWith(TestEnum); expect(IsEnum).toHaveBeenCalledWith(TestEnum);
expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform
}); });
it('should not apply enum validator without options', () => { it('should not apply enum validator without options', () => {
applyBasicValidators('enum', mockTarget, mockPropertyKey); applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
);
expect(IsEnum).not.toHaveBeenCalled(); expect(IsEnum).not.toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled(); expect(Transform).not.toHaveBeenCalled();
@ -144,7 +171,11 @@ describe('applyBasicValidators', () => {
describe('array type', () => { describe('array type', () => {
it('should apply array validator', () => { it('should apply array validator', () => {
applyBasicValidators('array', mockTarget, mockPropertyKey); applyBasicValidators(
ConfigVariableType.ARRAY,
mockTarget,
mockPropertyKey,
);
expect(IsArray).toHaveBeenCalled(); expect(IsArray).toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled(); // Array doesn't need a transform expect(Transform).not.toHaveBeenCalled(); // Array doesn't need a transform

View File

@ -7,8 +7,12 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type'; import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/types/config-variable-type.type';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
export function applyBasicValidators( export function applyBasicValidators(
@ -18,7 +22,7 @@ export function applyBasicValidators(
options?: ConfigVariableOptions, options?: ConfigVariableOptions,
): void { ): void {
switch (type) { switch (type) {
case 'boolean': case ConfigVariableType.BOOLEAN:
Transform(({ value }) => { Transform(({ value }) => {
const result = configTransformers.boolean(value); const result = configTransformers.boolean(value);
@ -27,7 +31,7 @@ export function applyBasicValidators(
IsBoolean()(target, propertyKey); IsBoolean()(target, propertyKey);
break; break;
case 'number': case ConfigVariableType.NUMBER:
Transform(({ value }) => { Transform(({ value }) => {
const result = configTransformers.number(value); const result = configTransformers.number(value);
@ -36,21 +40,24 @@ export function applyBasicValidators(
IsNumber()(target, propertyKey); IsNumber()(target, propertyKey);
break; break;
case 'string': case ConfigVariableType.STRING:
IsString()(target, propertyKey); IsString()(target, propertyKey);
break; break;
case 'enum': case ConfigVariableType.ENUM:
if (options) { if (options) {
IsEnum(options)(target, propertyKey); IsEnum(options)(target, propertyKey);
} }
break; break;
case 'array': case ConfigVariableType.ARRAY:
IsArray()(target, propertyKey); IsArray()(target, propertyKey);
break; break;
default: default:
throw new Error(`Unsupported config variable type: ${type}`); throw new ConfigVariableException(
`Unsupported config variable type: ${type}`,
ConfigVariableExceptionCode.UNSUPPORTED_CONFIG_TYPE,
);
} }
} }

View File

@ -0,0 +1 @@
export type ConfigVariableValue = string | number | boolean | string[] | null;

View File

@ -7,6 +7,7 @@
* |___/ * |___/
*/ */
export type { ConfigVariableValue } from './ConfigVariableValue';
export { ConnectedAccountProvider } from './ConnectedAccountProvider'; export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export { FieldMetadataType } from './FieldMetadataType'; export { FieldMetadataType } from './FieldMetadataType';
export type { IsExactly } from './IsExactly'; export type { IsExactly } from './IsExactly';

View File

@ -238,6 +238,7 @@ export {
IconPuzzle, IconPuzzle,
IconQuestionMark, IconQuestionMark,
IconRefresh, IconRefresh,
IconRefreshAlert,
IconRefreshDot, IconRefreshDot,
IconRelationManyToMany, IconRelationManyToMany,
IconRelationOneToMany, IconRelationOneToMany,

View File

@ -299,6 +299,7 @@ export {
IconPuzzle, IconPuzzle,
IconQuestionMark, IconQuestionMark,
IconRefresh, IconRefresh,
IconRefreshAlert,
IconRefreshDot, IconRefreshDot,
IconRelationManyToMany, IconRelationManyToMany,
IconRelationOneToMany, IconRelationOneToMany,

View File

@ -1,11 +1,18 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
type H3TitleProps = { type H3TitleProps = {
title: ReactNode; title: ReactNode;
description?: string;
className?: string; className?: string;
}; };
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledH3Title = styled.h3` const StyledH3Title = styled.h3`
color: ${({ theme }) => theme.font.color.primary}; color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.lg}; font-size: ${({ theme }) => theme.font.size.lg};
@ -13,6 +20,29 @@ const StyledH3Title = styled.h3`
margin: 0; margin: 0;
`; `;
export const H3Title = ({ title, className }: H3TitleProps) => { const StyledDescription = styled.h4`
return <StyledH3Title className={className}>{title}</StyledH3Title>; color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin: 0;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const H3Title = ({ title, description, className }: H3TitleProps) => {
return (
<StyledContainer className={className}>
<StyledH3Title>{title}</StyledH3Title>
{description && (
// Design rule: Never set a description for H3 if there are any H2 in the page
// (in that case, each H2 must have its own description)
<StyledDescription>
<OverflowingTextWithTooltip
text={description}
displayedMaxRows={2}
isTooltipMultiline={true}
/>
</StyledDescription>
)}
</StyledContainer>
);
}; };