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 "APP_SECRET=$(openssl rand -base64 32)" >> 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..."
cd packages/twenty-docker/

View File

@ -301,6 +301,7 @@ export type ClientConfig = {
defaultSubdomain?: Maybe<Scalars['String']>;
frontDomain: Scalars['String'];
isAttachmentPreviewEnabled: Scalars['Boolean'];
isConfigVariablesInDbEnabled: Scalars['Boolean'];
isEmailVerificationRequired: Scalars['Boolean'];
isGoogleCalendarEnabled: Scalars['Boolean'];
isGoogleMessagingEnabled: Scalars['Boolean'];
@ -318,14 +319,32 @@ export type ComputeStepOutputSchemaInput = {
step: Scalars['JSON'];
};
export enum ConfigSource {
DATABASE = 'DATABASE',
DEFAULT = 'DEFAULT',
ENVIRONMENT = 'ENVIRONMENT'
}
export type ConfigVariable = {
__typename?: 'ConfigVariable';
description: Scalars['String'];
isEnvOnly: Scalars['Boolean'];
isSensitive: Scalars['Boolean'];
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 {
AnalyticsConfig = 'AnalyticsConfig',
BillingConfig = 'BillingConfig',
@ -868,6 +887,7 @@ export type Mutation = {
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createApprovedAccessDomain: ApprovedAccessDomain;
createDatabaseConfigVariable: Scalars['Boolean'];
createDraftFromWorkflowVersion: WorkflowVersion;
createOIDCIdentityProvider: SetupSsoOutput;
createOneAppToken: AppToken;
@ -880,6 +900,7 @@ export type Mutation = {
deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteOneField: Field;
deleteOneObject: Object;
deleteOneRole: Scalars['String'];
@ -914,6 +935,7 @@ export type Mutation = {
switchToYearlyInterval: BillingUpdateOutput;
track: Analytics;
trackAnalytics: Analytics;
updateDatabaseConfigVariable: Scalars['Boolean'];
updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneField: Field;
updateOneObject: Object;
@ -971,6 +993,12 @@ export type MutationCreateApprovedAccessDomainArgs = {
};
export type MutationCreateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
};
export type MutationCreateDraftFromWorkflowVersionArgs = {
input: CreateDraftFromWorkflowVersionInput;
};
@ -1016,6 +1044,11 @@ export type MutationDeleteApprovedAccessDomainArgs = {
};
export type MutationDeleteDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
export type MutationDeleteOneFieldArgs = {
input: DeleteOneFieldInput;
};
@ -1162,6 +1195,12 @@ export type MutationTrackAnalyticsArgs = {
};
export type MutationUpdateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
};
export type MutationUpdateLabPublicFeatureFlagArgs = {
input: UpdateLabPublicFeatureFlagInput;
};
@ -1485,6 +1524,7 @@ export type Query = {
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON'];
getConfigVariablesGrouped: ConfigVariablesOutput;
getDatabaseConfigVariable: ConfigVariable;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
getMeteredProductsUsage: Array<BillingMeteredProductUsageOutput>;
getPostgresCredentials?: Maybe<PostgresCredentials>;
@ -1540,6 +1580,11 @@ export type QueryGetAvailablePackagesArgs = {
};
export type QueryGetDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
export type QueryGetIndicatorHealthStatusArgs = {
indicatorId: HealthIndicatorId;
};
@ -2665,7 +2710,7 @@ export type SwitchSubscriptionToYearlyIntervalMutation = { __typename?: 'Mutatio
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<{
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 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<{
workspaceId: 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 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; }>;
@ -4510,6 +4585,7 @@ export const GetClientConfigDocument = gql`
isMicrosoftCalendarEnabled
isGoogleMessagingEnabled
isGoogleCalendarEnabled
isConfigVariablesInDbEnabled
}
}
`;
@ -4622,6 +4698,191 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta
export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType<typeof useSkipSyncEmailOnboardingStepMutation>;
export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult<SkipSyncEmailOnboardingStepMutation>;
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`
mutation UpdateWorkspaceFeatureFlag($workspaceId: String!, $featureFlag: String!, $value: Boolean!) {
updateWorkspaceFeatureFlag(
@ -4714,50 +4975,6 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo
export type UserLookupAdminPanelMutationHookResult = ReturnType<typeof useUserLookupAdminPanelMutation>;
export type UserLookupAdminPanelMutationResult = Apollo.MutationResult<UserLookupAdminPanelMutation>;
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`
query GetVersionInfo {
versionInfo {

View File

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

View File

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

View File

@ -61,6 +61,7 @@ export const GET_CLIENT_CONFIG = gql`
isMicrosoftCalendarEnabled
isGoogleMessagingEnabled
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,
},
{
id: SETTINGS_ADMIN_TABS.ENV_VARIABLES,
title: t`Env Variables`,
id: SETTINGS_ADMIN_TABS.CONFIG_VARIABLES,
title: t`Config Variables`,
Icon: IconVariable,
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 { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariables';
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
@ -15,8 +15,8 @@ export const SettingsAdminTabContent = () => {
switch (activeTabId) {
case SETTINGS_ADMIN_TABS.GENERAL:
return <SettingsAdminGeneral />;
case SETTINGS_ADMIN_TABS.ENV_VARIABLES:
return <SettingsAdminEnvVariables />;
case SETTINGS_ADMIN_TABS.CONFIG_VARIABLES:
return <SettingsAdminConfigVariables />;
case SETTINGS_ADMIN_TABS.HEALTH_STATUS:
return <SettingsAdminHealthStatus />;
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 styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useDebouncedCallback } from 'use-debounce';
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`
white-space: nowrap;
@ -20,17 +27,12 @@ const StyledExpandedEllipsisLabel = styled.div`
const StyledCopyContainer = styled.span`
cursor: pointer;
`;
export const SettingsAdminEnvCopyableText = ({
export const SettingsAdminConfigCopyableText = ({
text,
displayText,
multiline = false,
maxRows,
}: {
text: string;
displayText?: React.ReactNode;
multiline?: boolean;
maxRows?: number;
}) => {
}: SettingsAdminConfigCopyableTextProps) => {
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
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
value
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 = {
GENERAL: 'general',
ENV_VARIABLES: 'env-variables',
CONFIG_VARIABLES: 'config-variables',
HEALTH_STATUS: 'health-status',
};

View File

@ -3,11 +3,11 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
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 { Select } from '@/ui/input/components/Select';
import { useLingui } from '@lingui/react/macro';
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({
defaultValue: z.boolean(),

View File

@ -39,7 +39,7 @@ export enum SettingsPath {
AdminPanel = 'admin-panel',
AdminPanelHealthStatus = 'admin-panel#health-status',
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorId',
AdminPanelOtherEnvVariables = 'admin-panel/other-env-variables',
AdminPanelConfigVariableDetails = 'admin-panel/config-variables/:variableName',
Lab = 'lab',
Roles = 'roles',
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,
isGoogleCalendarEnabled: 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;
},
queryParams?: Record<string, any>,
hash?: string,
) => {
let path = `/${AppPath.Settings}/${to}`;
@ -32,5 +33,9 @@ export const getSettingsPath = <T extends SettingsPath>(
}
}
if (isDefined(hash)) {
path += `#${hash.replace(/^#/, '')}`;
}
return path;
};

View File

@ -16,6 +16,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
const UserFindOneMock = jest.fn();
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
const TwentyConfigServiceGetAllMock = jest.fn();
const TwentyConfigServiceGetVariableWithMetadataMock = jest.fn();
jest.mock(
'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata',
@ -72,6 +73,8 @@ describe('AdminPanelService', () => {
provide: TwentyConfigService,
useValue: {
getAll: TwentyConfigServiceGetAllMock,
getVariableWithMetadata:
TwentyConfigServiceGetVariableWithMetadataMock,
},
},
],
@ -165,14 +168,20 @@ describe('AdminPanelService', () => {
metadata: {
group: 'SERVER_CONFIG',
description: 'Server URL',
type: 'string',
options: undefined,
},
source: 'env',
},
RATE_LIMIT_TTL: {
value: '60',
value: 60,
metadata: {
group: 'RATE_LIMITING',
description: 'Rate limit TTL',
type: 'number',
options: undefined,
},
source: 'env',
},
API_KEY: {
value: 'secret-key',
@ -180,14 +189,20 @@ describe('AdminPanelService', () => {
group: 'SERVER_CONFIG',
description: 'API Key',
isSensitive: true,
type: 'string',
options: undefined,
},
source: 'env',
},
OTHER_VAR: {
value: 'other',
metadata: {
group: 'OTHER',
description: 'Other var',
type: 'string',
options: undefined,
},
source: 'env',
},
});
@ -205,12 +220,20 @@ describe('AdminPanelService', () => {
value: 'secret-key',
description: 'API Key',
isSensitive: true,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
},
{
name: 'SERVER_URL',
value: 'http://localhost',
description: 'Server URL',
isSensitive: false,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
},
],
},
@ -221,9 +244,13 @@ describe('AdminPanelService', () => {
variables: [
{
name: 'RATE_LIMIT_TTL',
value: '60',
value: 60,
description: 'Rate limit TTL',
isSensitive: false,
isEnvOnly: false,
type: 'number',
options: undefined,
source: 'env',
},
],
},
@ -237,6 +264,10 @@ describe('AdminPanelService', () => {
value: 'other',
description: 'Other var',
isSensitive: false,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
},
],
},
@ -264,7 +295,10 @@ describe('AdminPanelService', () => {
value: 'test',
metadata: {
group: 'SERVER_CONFIG',
type: 'string',
options: undefined,
},
source: 'env',
},
});
@ -275,6 +309,10 @@ describe('AdminPanelService', () => {
value: 'test',
description: undefined,
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 { 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 { 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 { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
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 { 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 { 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 { ImpersonateGuard } from 'src/engine/guards/impersonate-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';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
@UseFilters(
AuthGraphqlApiExceptionFilter,
ConfigVariableGraphqlApiExceptionFilter,
)
export class AdminPanelResolver {
constructor(
private adminService: AdminPanelService,
private adminPanelHealthService: AdminPanelHealthService,
private featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@ -119,4 +129,48 @@ export class AdminPanelResolver {
async versionInfo(): Promise<VersionInfo> {
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 { 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 { 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 { 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';
@ -127,14 +128,20 @@ export class AdminPanelService {
const rawEnvVars = this.twentyConfigService.getAll();
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 envVar: ConfigVariable = {
name: varName,
description,
value: String(value),
value: value ?? null,
isSensitive: metadata.isSensitive ?? false,
isEnvOnly: metadata.isEnvOnly ?? false,
type: metadata.type,
options: metadata.options,
source,
};
if (!groupedData.has(group)) {
@ -161,6 +168,30 @@ export class AdminPanelService {
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> {
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()
export class ConfigVariable {
@ -8,9 +23,21 @@ export class ConfigVariable {
@Field()
description: string;
@Field()
value: string;
@Field(() => GraphQLJSON, { nullable: true })
value: ConfigVariableValue;
@Field()
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)
isGoogleCalendarEnabled: boolean;
@Field(() => Boolean)
isConfigVariablesInDbEnabled: boolean;
}

View File

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

View File

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

View File

@ -4,9 +4,9 @@ import {
ValidationOptions,
} 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 { 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 { TypedReflect } from 'src/utils/typed-reflect';
@ -15,7 +15,7 @@ export interface ConfigVariablesMetadataOptions {
description: string;
isSensitive?: boolean;
isEnvOnly?: boolean;
type?: ConfigVariableType;
type: ConfigVariableType;
options?: ConfigVariableOptions;
}
@ -51,14 +51,12 @@ export function ConfigVariablesMetadata(
IsOptional()(target, propertyKey);
}
if (options.type) {
applyBasicValidators(
options.type,
target,
propertyKey.toString(),
options.options,
);
}
applyBasicValidators(
options.type,
target,
propertyKey.toString(),
options.options,
);
registerDecorator({
name: propertyKey.toString(),

View File

@ -19,12 +19,10 @@ const CONFIG_ENV_ONLY_KEY = 'ENV_ONLY_VAR';
const CONFIG_PORT_KEY = 'NODE_PORT';
class TestDatabaseConfigDriver extends DatabaseConfigDriver {
// Expose the protected/private property for testing
public get testAllPossibleConfigKeys(): Array<keyof ConfigVariables> {
return this['allPossibleConfigKeys'];
}
// Override Object.keys usage in constructor with our test keys
constructor(
configCache: ConfigCacheService,
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', () => {
it('should return cache info', () => {
const cacheInfo = {

View File

@ -56,6 +56,18 @@ export class DatabaseConfigDriver
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>(
key: T,
value: ConfigVariables[T],
@ -66,43 +78,8 @@ export class DatabaseConfigDriver
);
}
try {
await this.configStorage.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);
}
await this.configStorage.set(key, value);
this.configCache.set(key, value);
}
getCacheInfo(): {
@ -114,39 +91,29 @@ export class DatabaseConfigDriver
}
private async loadAllConfigVarsFromDb(): Promise<number> {
try {
this.logger.debug('[LOAD] Fetching all config variables from database');
const configVars = await this.configStorage.loadAll();
const configVars = await this.configStorage.loadAll();
this.logger.debug(
`[LOAD] Processing ${this.allPossibleConfigKeys.length} possible config variables`,
);
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, value] of configVars.entries()) {
this.configCache.set(key, value);
}
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)
async refreshAllCache(): Promise<void> {
try {
this.logger.debug(
'[REFRESH] Starting scheduled refresh of config variables',
);
const dbValues = await this.configStorage.loadAll();
this.logger.debug(
`[REFRESH] Processing ${this.allPossibleConfigKeys.length} possible config variables`,
);
for (const [key, value] of dbValues.entries()) {
if (!isEnvOnlyConfigVar(key)) {
this.configCache.set(key, value);
@ -178,16 +137,12 @@ export class DatabaseConfigDriver
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) {
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
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 { 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 { 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';
@Module({})
@ -24,6 +25,7 @@ export class DatabaseConfigModule {
ConfigCacheService,
ConfigStorageService,
ConfigValueConverterService,
EnvironmentConfigDriver,
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: new ConfigVariables(),

View File

@ -19,11 +19,6 @@ export interface DatabaseConfigDriverInterface {
value: ConfigVariables[T],
): Promise<void>;
/**
* Fetch and cache a specific configuration from its source
*/
fetchAndCacheConfigVariable(key: keyof ConfigVariables): Promise<void>;
/**
* 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 * as authUtils from 'src/engine/core-modules/auth/auth.util';
import {
KeyValuePair,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
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 { 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 { User } from 'src/engine/core-modules/user/user.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', () => {
let service: ConfigStorageService;
let keyValuePairRepository: Repository<KeyValuePair>;
let configValueConverter: ConfigValueConverterService;
let environmentConfigDriver: EnvironmentConfigDriver;
const createMockKeyValuePair = (
key: string,
@ -47,6 +58,12 @@ describe('ConfigStorageService', () => {
convertAppValueToDbValue: jest.fn(),
},
},
{
provide: EnvironmentConfigDriver,
useValue: {
get: jest.fn().mockReturnValue('test-secret'),
},
},
ConfigVariables,
{
provide: getRepositoryToken(KeyValuePair, 'core'),
@ -68,6 +85,9 @@ describe('ConfigStorageService', () => {
configValueConverter = module.get<ConfigValueConverterService>(
ConfigValueConverterService,
);
environmentConfigDriver = module.get<EnvironmentConfigDriver>(
EnvironmentConfigDriver,
);
jest.clearAllMocks();
});
@ -136,6 +156,188 @@ describe('ConfigStorageService', () => {
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', () => {
@ -197,6 +399,77 @@ describe('ConfigStorageService', () => {
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', () => {
@ -315,6 +588,47 @@ describe('ConfigStorageService', () => {
).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', () => {

View File

@ -3,12 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, IsNull, Repository } from 'typeorm';
import {
decryptText,
encryptText,
} from 'src/engine/core-modules/auth/auth.util';
import {
KeyValuePair,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
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 { 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';
@ -20,6 +27,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
@InjectRepository(KeyValuePair, 'core')
private readonly keyValuePairRepository: Repository<KeyValuePair>,
private readonly configValueConverter: ConfigValueConverterService,
private readonly environmentConfigDriver: EnvironmentConfigDriver,
) {}
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>(
key: T,
): Promise<ConfigVariables[T] | undefined> {
@ -45,25 +114,13 @@ export class ConfigStorageService implements ConfigStorageInterface {
return undefined;
}
try {
this.logger.debug(
`Fetching config for ${key as string} in database: ${result?.value}`,
);
this.logger.debug(
`Fetching config for ${key as string} in database: ${result?.value}`,
);
return this.configValueConverter.convertDbValueToAppValue(
result.value,
key,
);
} catch (error) {
this.logger.error(
`Failed to convert value to app type for key ${key as string}`,
error,
);
throw error;
}
return await this.convertAndSecureValue(result.value, key, true);
} catch (error) {
this.logger.error(`Failed to get config for ${key as string}`, error);
throw error;
this.logAndRethrow(`Failed to get config for ${key as string}`, error);
}
}
@ -72,18 +129,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
value: ConfigVariables[T],
): Promise<void> {
try {
let processedValue;
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 dbValue = await this.convertAndSecureValue(value, key, false);
const existingRecord = await this.keyValuePairRepository.findOne({
where: this.getConfigVariableWhereClause(key as string),
@ -92,20 +138,19 @@ export class ConfigStorageService implements ConfigStorageInterface {
if (existingRecord) {
await this.keyValuePairRepository.update(
{ id: existingRecord.id },
{ value: processedValue },
{ value: dbValue },
);
} else {
await this.keyValuePairRepository.insert({
key: key as string,
value: processedValue,
value: dbValue,
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
}
} catch (error) {
this.logger.error(`Failed to set config for ${key as string}`, error);
throw error;
this.logAndRethrow(`Failed to set config for ${key as string}`, error);
}
}
@ -115,8 +160,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
this.getConfigVariableWhereClause(key as string),
);
} catch (error) {
this.logger.error(`Failed to delete config for ${key as string}`, error);
throw error;
this.logAndRethrow(`Failed to delete config for ${key as string}`, error);
}
}
@ -138,9 +182,10 @@ export class ConfigStorageService implements ConfigStorageInterface {
const key = configVar.key as keyof ConfigVariables;
try {
const value = this.configValueConverter.convertDbValueToAppValue(
const value = await this.convertAndSecureValue(
configVar.value,
key,
true,
);
if (value !== undefined) {
@ -148,7 +193,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
}
} catch (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,
);
continue;
@ -158,8 +203,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
return result;
} catch (error) {
this.logger.error('Failed to load all config variables', error);
throw error;
this.logAndRethrow('Failed to load all config variables', 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 { 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 { 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';
@ -58,7 +59,6 @@ const mockConfigVarMetadata = {
},
};
// Setup with database driver
const setupTestModule = async (isDatabaseConfigEnabled = true) => {
const configServiceMock = {
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({
providers: [
TwentyConfigService,
@ -77,8 +84,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
provide: DatabaseConfigDriver,
useValue: {
get: jest.fn(),
set: jest.fn(),
update: jest.fn(),
getCacheInfo: jest.fn(),
delete: jest.fn(),
},
},
{
@ -93,6 +102,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
provide: ConfigService,
useValue: configServiceMock,
},
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: mockConfigVariablesInstance,
},
],
}).compile();
@ -104,10 +117,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
EnvironmentConfigDriver,
),
configService: module.get<ConfigService>(ConfigService),
configVariablesInstance: module.get(CONFIG_VARIABLES_INSTANCE_TOKEN),
};
};
// Setup without database driver
const setupTestModuleWithoutDb = async () => {
const configServiceMock = {
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({
providers: [
TwentyConfigService,
@ -134,6 +154,10 @@ const setupTestModuleWithoutDb = async () => {
provide: ConfigService,
useValue: configServiceMock,
},
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: mockConfigVariablesInstance,
},
],
}).compile();
@ -143,6 +167,7 @@ const setupTestModuleWithoutDb = async () => {
EnvironmentConfigDriver,
),
configService: module.get<ConfigService>(ConfigService),
configVariablesInstance: module.get(CONFIG_VARIABLES_INSTANCE_TOKEN),
};
};
@ -278,6 +303,10 @@ describe('TwentyConfigService', () => {
});
describe('update', () => {
beforeEach(() => {
jest.spyOn(service, 'validateConfigVariableExists').mockReturnValue(true);
});
it('should throw error when database driver is not active', async () => {
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 { 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 { 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 { 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 { 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 { isEnvOnlyConfigVar } from 'src/engine/core-modules/twenty-config/utils/is-env-only-config-var.util';
import { TypedReflect } from 'src/utils/typed-reflect';
@ -21,6 +26,8 @@ export class TwentyConfigService {
constructor(
private readonly environmentConfigDriver: EnvironmentConfigDriver,
@Optional() private readonly databaseConfigDriver: DatabaseConfigDriver,
@Inject(CONFIG_VARIABLES_INSTANCE_TOKEN)
private readonly configVariablesInstance: ConfigVariables,
) {
const isConfigVariablesInDbEnabled = this.environmentConfigDriver.get(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
@ -59,49 +66,37 @@ export class TwentyConfigService {
if (cachedValueFromDb !== undefined) {
return cachedValueFromDb;
}
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>(
key: T,
value: ConfigVariables[T],
): Promise<void> {
if (!this.isDatabaseDriverActive) {
throw new Error(
'Database configuration is disabled or unavailable, cannot update configuration',
);
}
this.validateDatabaseDriverActive('update');
this.validateNotEnvOnly(key, 'update');
this.validateConfigVariableExists(key as string);
const metadata =
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;
}
await this.databaseConfigDriver.update(key, value);
}
getMetadata(
key: keyof ConfigVariables,
): ConfigVariablesMetadataOptions | undefined {
const metadata =
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
return metadata[key];
return this.getConfigMetadata()[key as string];
}
getAll(): Record<
@ -121,44 +116,14 @@ export class TwentyConfigService {
}
> = {};
const configVars = new ConfigVariables();
const metadata =
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
const metadata = this.getConfigMetadata();
Object.entries(metadata).forEach(([key, envMetadata]) => {
let value = this.get(key as keyof ConfigVariables) ?? '';
let source = ConfigSource.ENVIRONMENT;
const typedKey = key as keyof ConfigVariables;
let value = this.get(typedKey) ?? '';
const source = this.determineConfigSource(typedKey, value, envMetadata);
if (!this.isDatabaseDriverActive || envMetadata.isEnvOnly) {
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 },
);
}
value = this.maskSensitiveValue(typedKey, value);
result[key] = {
value,
@ -170,6 +135,29 @@ export class TwentyConfigService {
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(): {
usingDatabaseDriver: boolean;
cacheStats?: {
@ -191,4 +179,100 @@ export class TwentyConfigService {
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,
} 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 { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
@ -50,7 +51,11 @@ describe('applyBasicValidators', () => {
return jest.fn();
});
applyBasicValidators('boolean', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.BOOLEAN,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled();
expect(IsBoolean).toHaveBeenCalled();
@ -81,7 +86,11 @@ describe('applyBasicValidators', () => {
return jest.fn();
});
applyBasicValidators('number', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.NUMBER,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled();
expect(IsNumber).toHaveBeenCalled();
@ -104,7 +113,11 @@ describe('applyBasicValidators', () => {
describe('string type', () => {
it('should apply string validator', () => {
applyBasicValidators('string', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.STRING,
mockTarget,
mockPropertyKey,
);
expect(IsString).toHaveBeenCalled();
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', () => {
const enumOptions = ['option1', 'option2', 'option3'];
applyBasicValidators('enum', mockTarget, mockPropertyKey, enumOptions);
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
enumOptions,
);
expect(IsEnum).toHaveBeenCalledWith(enumOptions);
expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform
@ -128,14 +146,23 @@ describe('applyBasicValidators', () => {
Option3 = 'value3',
}
applyBasicValidators('enum', mockTarget, mockPropertyKey, TestEnum);
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
TestEnum,
);
expect(IsEnum).toHaveBeenCalledWith(TestEnum);
expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform
});
it('should not apply enum validator without options', () => {
applyBasicValidators('enum', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
);
expect(IsEnum).not.toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled();
@ -144,7 +171,11 @@ describe('applyBasicValidators', () => {
describe('array type', () => {
it('should apply array validator', () => {
applyBasicValidators('array', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.ARRAY,
mockTarget,
mockPropertyKey,
);
expect(IsArray).toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled(); // Array doesn't need a transform

View File

@ -7,8 +7,12 @@ import {
IsString,
} 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 { 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';
export function applyBasicValidators(
@ -18,7 +22,7 @@ export function applyBasicValidators(
options?: ConfigVariableOptions,
): void {
switch (type) {
case 'boolean':
case ConfigVariableType.BOOLEAN:
Transform(({ value }) => {
const result = configTransformers.boolean(value);
@ -27,7 +31,7 @@ export function applyBasicValidators(
IsBoolean()(target, propertyKey);
break;
case 'number':
case ConfigVariableType.NUMBER:
Transform(({ value }) => {
const result = configTransformers.number(value);
@ -36,21 +40,24 @@ export function applyBasicValidators(
IsNumber()(target, propertyKey);
break;
case 'string':
case ConfigVariableType.STRING:
IsString()(target, propertyKey);
break;
case 'enum':
case ConfigVariableType.ENUM:
if (options) {
IsEnum(options)(target, propertyKey);
}
break;
case 'array':
case ConfigVariableType.ARRAY:
IsArray()(target, propertyKey);
break;
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 { FieldMetadataType } from './FieldMetadataType';
export type { IsExactly } from './IsExactly';

View File

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

View File

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

View File

@ -1,11 +1,18 @@
import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip';
import { ReactNode } from 'react';
type H3TitleProps = {
title: ReactNode;
description?: string;
className?: string;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledH3Title = styled.h3`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.lg};
@ -13,6 +20,29 @@ const StyledH3Title = styled.h3`
margin: 0;
`;
export const H3Title = ({ title, className }: H3TitleProps) => {
return <StyledH3Title className={className}>{title}</StyledH3Title>;
const StyledDescription = styled.h4`
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>
);
};