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

@ -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;
}