Environment variables in admin panel (read only) - front (#10011)

Frontend for https://github.com/twentyhq/core-team-issues/issues/293

POC - https://github.com/twentyhq/twenty/pull/9903

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
nitin
2025-02-06 21:38:44 +05:30
committed by GitHub
parent a85c4f263a
commit 1b150e1da6
43 changed files with 1224 additions and 758 deletions

View File

@ -33,9 +33,33 @@ jest.mock(
);
jest.mock(
'src/engine/core-modules/environment/constants/environment-variables-hidden-groups',
'src/engine/core-modules/environment/constants/environment-variables-group-metadata',
() => ({
ENVIRONMENT_VARIABLES_HIDDEN_GROUPS: new Set(['HIDDEN_GROUP']),
ENVIRONMENT_VARIABLES_GROUP_METADATA: {
GROUP_1: {
position: 100,
description: '',
},
GROUP_2: {
position: 200,
description: '',
},
VISIBLE_GROUP: {
position: 300,
description: '',
},
},
}),
);
jest.mock(
'src/engine/core-modules/environment/constants/environment-variables-sub-group-metadata',
() => ({
ENVIRONMENT_VARIABLES_SUB_GROUP_METADATA: {
SUBGROUP_1: {
description: '',
},
},
}),
);
@ -262,9 +286,10 @@ describe('AdminPanelService', () => {
const result = service.getEnvironmentVariablesGrouped();
expect(result).toEqual({
groups: expect.arrayContaining([
expect.objectContaining({
groupName: 'GROUP_1',
groups: [
{
name: 'GROUP_1',
description: '',
variables: [
{
name: 'VAR_1',
@ -275,7 +300,8 @@ describe('AdminPanelService', () => {
],
subgroups: [
{
subgroupName: 'SUBGROUP_1',
name: 'SUBGROUP_1',
description: '',
variables: [
{
name: 'VAR_2',
@ -286,9 +312,10 @@ describe('AdminPanelService', () => {
],
},
],
}),
expect.objectContaining({
groupName: 'GROUP_2',
},
{
name: 'GROUP_2',
description: '',
variables: [
{
name: 'VAR_3',
@ -298,8 +325,8 @@ describe('AdminPanelService', () => {
},
],
subgroups: [],
}),
]),
},
],
});
});
@ -324,7 +351,7 @@ describe('AdminPanelService', () => {
const result = service.getEnvironmentVariablesGrouped();
const group = result.groups.find(
(g) => g.groupName === ('GROUP_1' as EnvironmentVariablesGroup),
(g) => g.name === ('GROUP_1' as EnvironmentVariablesGroup),
);
expect(group?.variables[0].name).toBe('A_VAR');
@ -340,34 +367,5 @@ describe('AdminPanelService', () => {
groups: [],
});
});
it('should exclude hidden groups from the output', () => {
EnvironmentServiceGetAllMock.mockReturnValue({
VAR_1: {
value: 'value1',
metadata: {
group: 'HIDDEN_GROUP',
description: 'Description 1',
},
},
VAR_2: {
value: 'value2',
metadata: {
group: 'VISIBLE_GROUP',
description: 'Description 2',
},
},
});
const result = service.getEnvironmentVariablesGrouped();
expect(result.groups).toHaveLength(1);
expect(result.groups[0].groupName).toBe('VISIBLE_GROUP');
expect(result.groups).not.toContainEqual(
expect.objectContaining({
groupName: 'HIDDEN_GROUP',
}),
);
});
});
});

View File

@ -12,8 +12,8 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { ENVIRONMENT_VARIABLES_GROUP_POSITION } from 'src/engine/core-modules/environment/constants/environment-variables-group-position';
import { ENVIRONMENT_VARIABLES_HIDDEN_GROUPS } from 'src/engine/core-modules/environment/constants/environment-variables-hidden-groups';
import { ENVIRONMENT_VARIABLES_GROUP_METADATA } from 'src/engine/core-modules/environment/constants/environment-variables-group-metadata';
import { ENVIRONMENT_VARIABLES_SUB_GROUP_METADATA } from 'src/engine/core-modules/environment/constants/environment-variables-sub-group-metadata';
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@ -180,10 +180,6 @@ export class AdminPanelService {
for (const [varName, { value, metadata }] of Object.entries(rawEnvVars)) {
const { group, subGroup, description } = metadata;
if (ENVIRONMENT_VARIABLES_HIDDEN_GROUPS.has(group)) {
continue;
}
const envVar: EnvironmentVariable = {
name: varName,
description,
@ -218,18 +214,23 @@ export class AdminPanelService {
groupedData.entries(),
)
.sort((a, b) => {
const positionA = ENVIRONMENT_VARIABLES_GROUP_POSITION[a[0]];
const positionB = ENVIRONMENT_VARIABLES_GROUP_POSITION[b[0]];
const positionA = ENVIRONMENT_VARIABLES_GROUP_METADATA[a[0]].position;
const positionB = ENVIRONMENT_VARIABLES_GROUP_METADATA[b[0]].position;
return positionA - positionB;
})
.map(([groupName, data]) => ({
groupName,
.map(([name, data]) => ({
name,
description: ENVIRONMENT_VARIABLES_GROUP_METADATA[name].description,
isHiddenOnLoad:
ENVIRONMENT_VARIABLES_GROUP_METADATA[name].isHiddenOnLoad,
variables: data.variables.sort((a, b) => a.name.localeCompare(b.name)),
subgroups: Array.from(data.subgroups.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([subgroupName, variables]) => ({
subgroupName,
.map(([name, variables]) => ({
name,
description:
ENVIRONMENT_VARIABLES_SUB_GROUP_METADATA[name].description,
variables,
})),
}));

View File

@ -18,5 +18,11 @@ export class EnvironmentVariablesGroupData {
subgroups: EnvironmentVariablesSubgroupData[];
@Field(() => EnvironmentVariablesGroup)
groupName: EnvironmentVariablesGroup;
name: EnvironmentVariablesGroup;
@Field(() => String, { defaultValue: '' })
description: string;
@Field(() => Boolean, { defaultValue: false })
isHiddenOnLoad: boolean;
}

View File

@ -14,5 +14,8 @@ export class EnvironmentVariablesSubgroupData {
variables: EnvironmentVariable[];
@Field(() => EnvironmentVariablesSubGroup)
subgroupName: EnvironmentVariablesSubGroup;
name: EnvironmentVariablesSubGroup;
@Field(() => String, { defaultValue: '' })
description: string;
}

View File

@ -21,11 +21,11 @@ import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/gu
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -72,16 +72,6 @@ export class GoogleAPIsAuthController {
const { workspaceMemberId, userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
const demoWorkspaceIds =
this.environmentService.get('DEMO_WORKSPACE_IDS');
if (demoWorkspaceIds.includes(workspaceId)) {
throw new AuthException(
'Cannot connect Google account to demo workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (!workspaceId) {
throw new AuthException(
'Workspace not found',

View File

@ -23,9 +23,9 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
@Controller('auth/microsoft-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -72,16 +72,6 @@ export class MicrosoftAPIsAuthController {
const { workspaceMemberId, userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
const demoWorkspaceIds =
this.environmentService.get('DEMO_WORKSPACE_IDS');
if (demoWorkspaceIds.includes(workspaceId)) {
throw new AuthException(
'Cannot connect Microsoft account to demo workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (!workspaceId) {
throw new AuthException(
'Workspace not found',

View File

@ -1,24 +1,26 @@
import { ENVIRONMENT_VARIABLES_GROUP_POSITION } from 'src/engine/core-modules/environment/constants/environment-variables-group-position';
import { ENVIRONMENT_VARIABLES_GROUP_METADATA } from 'src/engine/core-modules/environment/constants/environment-variables-group-metadata';
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
describe('ENVIRONMENT_VARIABLES_GROUP_POSITION', () => {
describe('ENVIRONMENT_VARIABLES_GROUP_METADATA', () => {
it('should include all EnvironmentVariablesGroup enum values', () => {
const enumValues = Object.values(EnvironmentVariablesGroup);
const positionKeys = Object.keys(ENVIRONMENT_VARIABLES_GROUP_POSITION);
const metadataKeys = Object.keys(ENVIRONMENT_VARIABLES_GROUP_METADATA);
enumValues.forEach((enumValue) => {
expect(positionKeys).toContain(enumValue);
expect(metadataKeys).toContain(enumValue);
});
positionKeys.forEach((key) => {
metadataKeys.forEach((key) => {
expect(enumValues).toContain(key);
});
expect(enumValues.length).toBe(positionKeys.length);
expect(enumValues.length).toBe(metadataKeys.length);
});
it('should have unique position values', () => {
const positions = Object.values(ENVIRONMENT_VARIABLES_GROUP_POSITION);
const positions = Object.values(ENVIRONMENT_VARIABLES_GROUP_METADATA).map(
(metadata) => metadata.position,
);
const uniquePositions = new Set(positions);
expect(positions.length).toBe(uniquePositions.size);

View File

@ -0,0 +1,44 @@
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
type GroupMetadata = {
position: number;
description: string;
isHiddenOnLoad: boolean;
};
export const ENVIRONMENT_VARIABLES_GROUP_METADATA: Record<
EnvironmentVariablesGroup,
GroupMetadata
> = {
[EnvironmentVariablesGroup.ServerConfig]: {
position: 100,
description: '',
isHiddenOnLoad: false,
},
[EnvironmentVariablesGroup.Authentication]: {
position: 400,
description: '',
isHiddenOnLoad: false,
},
[EnvironmentVariablesGroup.Email]: {
position: 800,
description: '',
isHiddenOnLoad: false,
},
[EnvironmentVariablesGroup.Workspace]: {
position: 1000,
description: '',
isHiddenOnLoad: false,
},
[EnvironmentVariablesGroup.Logging]: {
position: 1200,
description: '',
isHiddenOnLoad: false,
},
[EnvironmentVariablesGroup.Other]: {
position: 1700,
description:
"The variables in this section are mostly used for internal purposes (running our Cloud offering), but shouldn't usually be required for a simple self-hosted instance",
isHiddenOnLoad: true,
},
};

View File

@ -1,23 +1,31 @@
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
export const ENVIRONMENT_VARIABLES_GROUP_POSITION: Record<
export const ENVIRONMENT_VARIABLES_GROUP_METADATA: Record<
EnvironmentVariablesGroup,
number
{ position: number; description: string }
> = {
[EnvironmentVariablesGroup.ServerConfig]: 100,
[EnvironmentVariablesGroup.Database]: 200,
[EnvironmentVariablesGroup.Security]: 300,
[EnvironmentVariablesGroup.Authentication]: 400,
[EnvironmentVariablesGroup.Cache]: 500,
[EnvironmentVariablesGroup.QueueConfig]: 600,
[EnvironmentVariablesGroup.Storage]: 700,
[EnvironmentVariablesGroup.Email]: 800,
[EnvironmentVariablesGroup.Frontend]: 900,
[EnvironmentVariablesGroup.Workspace]: 1000,
[EnvironmentVariablesGroup.Analytics]: 1100,
[EnvironmentVariablesGroup.Logging]: 1200,
[EnvironmentVariablesGroup.Billing]: 1300,
[EnvironmentVariablesGroup.Support]: 1400,
[EnvironmentVariablesGroup.LLM]: 1500,
[EnvironmentVariablesGroup.Serverless]: 1600,
[EnvironmentVariablesGroup.ServerConfig]: {
position: 100,
description: '',
},
[EnvironmentVariablesGroup.Authentication]: {
position: 400,
description: '',
},
[EnvironmentVariablesGroup.Email]: {
position: 800,
description: '',
},
[EnvironmentVariablesGroup.Workspace]: {
position: 1000,
description: '',
},
[EnvironmentVariablesGroup.Logging]: {
position: 1200,
description: '',
},
[EnvironmentVariablesGroup.Other]: {
position: 1700,
description: '',
},
};

View File

@ -1,4 +0,0 @@
import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum';
export const ENVIRONMENT_VARIABLES_HIDDEN_GROUPS: Set<EnvironmentVariablesGroup> =
new Set([EnvironmentVariablesGroup.LLM]);

View File

@ -0,0 +1,71 @@
import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum';
type SubGroupMetadata = {
description: string;
};
export const ENVIRONMENT_VARIABLES_SUB_GROUP_METADATA: Record<
EnvironmentVariablesSubGroup,
SubGroupMetadata
> = {
[EnvironmentVariablesSubGroup.PasswordAuth]: {
description: '',
},
[EnvironmentVariablesSubGroup.GoogleAuth]: {
description: 'Configure Google integration (login, calendar, email)',
},
[EnvironmentVariablesSubGroup.MicrosoftAuth]: {
description: 'Configure Microsoft integration (login, calendar, email)',
},
[EnvironmentVariablesSubGroup.EmailSettings]: {
description:
'This is used for emails that are sent by the app such as invitations to join a workspace. This is not used to email CRM contacts.',
},
[EnvironmentVariablesSubGroup.StorageConfig]: {
description:
"By default, file uploads are stored on the local filesystem, which is suitable for traditional servers. However, for ephemeral deployment servers, it's essential to configure the variables here to set up an S3-compatible file system. This ensures that files remain unaffected by server redeploys.",
},
[EnvironmentVariablesSubGroup.TokensDuration]: {
description:
"These have been set to sensible default so you probably don't need to change them unless you have a specific use-case.",
},
[EnvironmentVariablesSubGroup.SSL]: {
description:
'Configure this if you want to setup SSL on your server or full end-to-end encryption. If you just want basic HTTPS, a simple setup like Cloudflare in flexible mode might be easier.',
},
[EnvironmentVariablesSubGroup.RateLimiting]: {
description:
'We use this to limit the number of requests to the server. This is useful to prevent abuse.',
},
[EnvironmentVariablesSubGroup.TinybirdConfig]: {
description:
"We're running a test to perform analytics within the app. This will evolve.",
},
[EnvironmentVariablesSubGroup.BillingConfig]: {
description:
'We use Stripe in our Cloud app to charge customers. Not relevant to Self-hosters.',
},
[EnvironmentVariablesSubGroup.ExceptionHandler]: {
description:
'By default, exceptions are sent to the logs. This should be enough for most self-hosting use-cases. For our cloud app we use Sentry.',
},
[EnvironmentVariablesSubGroup.SupportChatConfig]: {
description:
'We use this to setup a small support chat on the bottom left. Currently powered by Front.',
},
[EnvironmentVariablesSubGroup.CloudflareConfig]: {
description: '',
},
[EnvironmentVariablesSubGroup.CaptchaConfig]: {
description:
'This protects critical endpoints like login and signup with a captcha to prevent bot attacks. Likely unnecessary for self-hosting scenarios.',
},
[EnvironmentVariablesSubGroup.ServerlessConfig]: {
description:
'In our multi-tenant cloud app, we offload untrusted custom code from workflows to a serverless system (Lambda) for enhanced security and scalability. Self-hosters with a single tenant can typically ignore this configuration.',
},
[EnvironmentVariablesSubGroup.LLM]: {
description:
'Configure the LLM provider and model to use for the app. This is experimental and not linked to any public feature.',
},
};

View File

@ -1,18 +1,8 @@
export enum EnvironmentVariablesGroup {
Authentication = 'authentication',
Email = 'email',
Database = 'database',
Storage = 'storage',
ServerConfig = 'server-config',
QueueConfig = 'queue-config',
Logging = 'logging',
Cache = 'cache',
Analytics = 'analytics',
Billing = 'billing',
Frontend = 'frontend',
Security = 'security',
Serverless = 'serverless',
Support = 'support',
LLM = 'llm',
Workspace = 'workspace',
Other = 'other',
}

View File

@ -2,16 +2,17 @@ export enum EnvironmentVariablesSubGroup {
PasswordAuth = 'password-auth',
GoogleAuth = 'google-auth',
MicrosoftAuth = 'microsoft-auth',
SmtpConfig = 'smtp-config',
EmailSettings = 'email-settings',
S3Config = 's3-config',
Tokens = 'tokens',
StorageConfig = 'storage-config',
TokensDuration = 'tokens-duration',
SSL = 'ssl',
RateLimiting = 'rate-limiting',
LambdaConfig = 'lambda-config',
TinybirdConfig = 'tinybird-config',
StripeConfig = 'stripe-config',
SentryConfig = 'sentry-config',
FrontSupportConfig = 'front-support-config',
BillingConfig = 'billing-config',
ExceptionHandler = 'exception-handler',
SupportChatConfig = 'support-chat-config',
CloudflareConfig = 'cloudflare-config',
CaptchaConfig = 'captcha-config',
ServerlessConfig = 'serverless-config',
LLM = 'llm',
}

View File

@ -86,26 +86,5 @@ describe('EnvironmentService', () => {
expect(result.APP_SECRET.value).not.toBe('super-secret-value');
expect(result.APP_SECRET.value).toMatch(/^\*+[a-zA-Z0-9]{5}$/);
});
it('should use default value when environment variable is not set', () => {
const mockMetadata = {
DEBUG_PORT: {
title: 'Debug Port',
description: 'Debug port number',
},
};
Reflect.defineMetadata(
'environment-variables',
mockMetadata,
EnvironmentVariables,
);
jest.spyOn(configService, 'get').mockReturnValue(undefined);
const result = service.getAll();
expect(result.DEBUG_PORT.value).toBe(9000);
});
});
});

View File

@ -8,11 +8,10 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(WorkspaceAuthGuard, DemoEnvGuard)
@UseGuards(WorkspaceAuthGuard)
@Resolver()
export class FileUploadResolver {
constructor(private readonly fileUploadService: FileUploadService) {}

View File

@ -1,8 +1,8 @@
import { OnModuleDestroy } from '@nestjs/common';
import { JobsOptions, Queue, QueueOptions, Worker } from 'bullmq';
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared';
import { v4 } from 'uuid';
import {
QueueCronJobOptions,

View File

@ -46,7 +46,6 @@ import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@ -295,7 +294,6 @@ export class UserResolver {
return `${paths[0]}?token=${fileToken}`;
}
@UseGuards(DemoEnvGuard)
@Mutation(() => User)
async deleteUser(@AuthUser() { id: userId }: User) {
// Proceed with user deletion

View File

@ -10,8 +10,8 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared';
import { Repository } from 'typeorm';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
@ -39,7 +39,6 @@ import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter';
@ -151,7 +150,7 @@ export class WorkspaceResolver {
}
@Mutation(() => Workspace)
@UseGuards(DemoEnvGuard, WorkspaceAuthGuard)
@UseGuards(WorkspaceAuthGuard)
async deleteCurrentWorkspace(@AuthWorkspace() { id }: Workspace) {
return this.workspaceService.deleteWorkspace(id);
}