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:
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user