Add admin panel workspace detail page with chat viewer (#19579)

## Overview
Adds comprehensive admin panel functionality for viewing workspace
details and AI chat threads.

## Changes

### Frontend
- **New Routes**: Added `AdminPanelWorkspaceDetail` and
`AdminPanelWorkspaceChatThread` pages with lazy loading
- **New Queries**: 
  - `getAdminWorkspaceChatThreads` - fetch chat threads for a workspace
  - `getAdminChatThreadMessages` - fetch messages for a specific thread
  - `workspaceLookupAdminPanel` - lookup workspace info and users
- **New Components**:
- `SettingsAdminWorkspaceDetail` - displays workspace info and chat
sessions tabs
- `SettingsAdminWorkspaceChatThread` - renders chat conversation with
message bubbles
- **Navigation**: Updated AI admin panel to link to workspace detail
pages
- **Settings Paths**: Added `AdminPanelWorkspaceDetail` and
`AdminPanelWorkspaceChatThread` paths

### Backend
- **New DTOs**:
  - `AdminWorkspaceChatThreadDTO` - workspace chat thread data
  - `AdminChatThreadMessagesDTO` - thread with messages
  - `AdminChatMessageDTO` - individual message with parts
- **New Resolvers**: Added three queries to `AdminPanelResolver`
- **New Service Methods**:
  - `workspaceLookup()` - fetch workspace info
  - `getWorkspaceChatThreads()` - list chat threads
  - `getChatThreadMessages()` - fetch thread messages with validation
- **Module Updates**: Added entity imports for workspace, user, AI chat,
and feature flag data

### Security
- Added `allowImpersonation` check before accessing chat data
- Validates workspace ownership and access permissions

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-13 09:47:34 +02:00
committed by GitHub
parent 150c326369
commit 09806d7d8c
49 changed files with 3456 additions and 1592 deletions
@@ -1,17 +1,10 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
const UserFindOneMock = jest.fn();
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
const TwentyConfigServiceGetAllMock = jest.fn();
const TwentyConfigServiceGetVariableWithMetadataMock = jest.fn();
const mockHttpClientGet = jest.fn();
@@ -40,34 +33,13 @@ jest.mock(
}),
);
describe('AdminPanelService', () => {
let service: AdminPanelService;
describe('AdminPanelConfigService', () => {
let configService: AdminPanelConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminPanelService,
{
provide: getRepositoryToken(UserEntity),
useValue: {
findOne: UserFindOneMock,
},
},
{
provide: LoginTokenService,
useValue: {
generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
},
},
{
provide: WorkspaceDomainsService,
useValue: {
getWorkspaceUrls: jest.fn().mockReturnValue({
customUrl: undefined,
subdomainUrl: 'https://twenty.twenty.com',
}),
},
},
AdminPanelConfigService,
{
provide: TwentyConfigService,
useValue: {
@@ -76,32 +48,16 @@ describe('AdminPanelService', () => {
TwentyConfigServiceGetVariableWithMetadataMock,
},
},
{
provide: AuditService,
useValue: {
createContext: jest.fn().mockReturnValue({
insertWorkspaceEvent: jest.fn(),
}),
},
},
{
provide: FileUrlService,
useValue: {},
},
{
provide: SecureHttpClientService,
useValue: {
getHttpClient: mockGetHttpClient,
},
},
],
}).compile();
service = module.get<AdminPanelService>(AdminPanelService);
configService = module.get<AdminPanelConfigService>(
AdminPanelConfigService,
);
});
it('should be defined', async () => {
expect(service).toBeDefined();
expect(configService).toBeDefined();
});
describe('getConfigVariablesGrouped', () => {
@@ -150,7 +106,7 @@ describe('AdminPanelService', () => {
},
});
const result = service.getConfigVariablesGrouped();
const result = configService.getConfigVariablesGrouped();
expect(result).toEqual({
groups: [
@@ -226,7 +182,7 @@ describe('AdminPanelService', () => {
it('should handle empty config variables', () => {
TwentyConfigServiceGetAllMock.mockReturnValue({});
const result = service.getConfigVariablesGrouped();
const result = configService.getConfigVariablesGrouped();
expect(result).toEqual({
groups: [],
@@ -246,7 +202,7 @@ describe('AdminPanelService', () => {
},
});
const result = service.getConfigVariablesGrouped();
const result = configService.getConfigVariablesGrouped();
expect(result.groups[0].variables[0]).toEqual({
name: 'TEST_VAR',
@@ -261,13 +217,79 @@ 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 = configService.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(() => configService.getConfigVariable('INVALID_VAR')).toThrow(
'Config variable INVALID_VAR not found',
);
});
});
});
describe('AdminPanelVersionService', () => {
let versionService: AdminPanelVersionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminPanelVersionService,
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
{
provide: SecureHttpClientService,
useValue: {
getHttpClient: mockGetHttpClient,
},
},
],
}).compile();
versionService = module.get<AdminPanelVersionService>(
AdminPanelVersionService,
);
});
describe('getVersionInfo', () => {
const mockEnvironmentGet = jest.fn();
beforeEach(() => {
mockEnvironmentGet.mockReset();
mockHttpClientGet.mockReset();
service['twentyConfigService'].get = mockEnvironmentGet;
versionService['twentyConfigService'].get = mockEnvironmentGet;
});
it('should return current and latest version when everything works', async () => {
@@ -283,7 +305,7 @@ describe('AdminPanelService', () => {
},
});
const result = await service.getVersionInfo();
const result = await versionService.getVersionInfo();
expect(result).toEqual({
currentVersion: '1.0.0',
@@ -299,7 +321,7 @@ describe('AdminPanelService', () => {
},
});
const result = await service.getVersionInfo();
const result = await versionService.getVersionInfo();
expect(result).toEqual({
currentVersion: undefined,
@@ -311,7 +333,7 @@ describe('AdminPanelService', () => {
mockEnvironmentGet.mockReturnValue('1.0.0');
mockHttpClientGet.mockRejectedValue(new Error('API Error'));
const result = await service.getVersionInfo();
const result = await versionService.getVersionInfo();
expect(result).toEqual({
currentVersion: '1.0.0',
@@ -327,7 +349,7 @@ describe('AdminPanelService', () => {
},
});
const result = await service.getVersionInfo();
const result = await versionService.getVersionInfo();
expect(result).toEqual({
currentVersion: '1.0.0',
@@ -348,7 +370,7 @@ describe('AdminPanelService', () => {
},
});
const result = await service.getVersionInfo();
const result = await versionService.getVersionInfo();
expect(result).toEqual({
currentVersion: '1.0.0',
@@ -356,42 +378,4 @@ 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',
);
});
});
});
@@ -5,7 +5,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
@@ -24,15 +28,26 @@ import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-cl
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, WorkspaceEntity]),
TypeOrmModule.forFeature([
UserEntity,
WorkspaceEntity,
UserWorkspaceEntity,
FeatureFlagEntity,
AgentChatThreadEntity,
AgentMessageEntity,
]),
AuthModule,
FileModule,
WorkspaceDomainsModule,
@@ -52,7 +67,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
],
providers: [
AdminPanelResolver,
AdminPanelService,
AdminPanelUserLookupService,
AdminPanelStatisticsService,
AdminPanelChatService,
AdminPanelConfigService,
AdminPanelVersionService,
AdminPanelHealthService,
AdminPanelQueueService,
MaintenanceModeService,
@@ -62,6 +81,13 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
ConnectedAccountHealth,
AppHealthIndicator,
],
exports: [AdminPanelService, MaintenanceModeService],
exports: [
AdminPanelUserLookupService,
AdminPanelStatisticsService,
AdminPanelChatService,
AdminPanelConfigService,
AdminPanelVersionService,
MaintenanceModeService,
],
})
export class AdminPanelModule {}
@@ -7,10 +7,20 @@ import { In, type Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto';
import { AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto';
import { AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
import { AdminChatThreadMessagesDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-messages.dto';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
@@ -76,7 +86,11 @@ import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
)
export class AdminPanelResolver {
constructor(
private readonly adminService: AdminPanelService,
private readonly adminUserLookupService: AdminPanelUserLookupService,
private readonly adminStatisticsService: AdminPanelStatisticsService,
private readonly adminChatService: AdminPanelChatService,
private readonly adminConfigService: AdminPanelConfigService,
private readonly adminVersionService: AdminPanelVersionService,
private readonly adminPanelHealthService: AdminPanelHealthService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private adminPanelQueueService: AdminPanelQueueService,
@@ -91,11 +105,39 @@ export class AdminPanelResolver {
) {}
@UseGuards(ServerLevelImpersonateGuard)
@Mutation(() => UserLookup)
@Query(() => UserLookup)
async userLookupAdminPanel(
@Args() userLookupInput: UserLookupInput,
): Promise<UserLookup> {
return await this.adminService.userLookup(userLookupInput.userIdentifier);
return await this.adminUserLookupService.userLookup(
userLookupInput.userIdentifier,
);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => [AdminPanelRecentUserDTO])
async adminPanelRecentUsers(
@Args('searchTerm', {
type: () => String,
nullable: true,
defaultValue: '',
})
searchTerm: string,
): Promise<AdminPanelRecentUserDTO[]> {
return this.adminStatisticsService.getRecentUsers(searchTerm);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => [AdminPanelTopWorkspaceDTO])
async adminPanelTopWorkspaces(
@Args('searchTerm', {
type: () => String,
nullable: true,
defaultValue: '',
})
searchTerm: string,
): Promise<AdminPanelTopWorkspaceDTO[]> {
return this.adminStatisticsService.getTopWorkspaces(searchTerm);
}
@UseGuards(AdminPanelGuard)
@@ -123,7 +165,7 @@ export class AdminPanelResolver {
@UseGuards(AdminPanelGuard)
@Query(() => ConfigVariablesDTO)
async getConfigVariablesGrouped(): Promise<ConfigVariablesDTO> {
return this.adminService.getConfigVariablesGrouped();
return this.adminConfigService.getConfigVariablesGrouped();
}
@UseGuards(AdminPanelGuard)
@@ -164,7 +206,7 @@ export class AdminPanelResolver {
@UseGuards(AdminPanelGuard)
@Query(() => VersionInfoDTO)
async versionInfo(): Promise<VersionInfoDTO> {
return this.adminService.getVersionInfo();
return this.adminVersionService.getVersionInfo();
}
@UseGuards(AdminPanelGuard)
@@ -282,7 +324,7 @@ export class AdminPanelResolver {
): Promise<ConfigVariableDTO> {
this.twentyConfigService.validateConfigVariableExists(key as string);
return this.adminService.getConfigVariable(key);
return this.adminConfigService.getConfigVariable(key);
}
@UseGuards(AdminPanelGuard)
@@ -614,4 +656,28 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => UserLookup)
async workspaceLookupAdminPanel(
@Args('workspaceId', { type: () => UUIDScalarType }) workspaceId: string,
): Promise<UserLookup> {
return this.adminUserLookupService.workspaceLookup(workspaceId);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => [AdminWorkspaceChatThreadDTO])
async getAdminWorkspaceChatThreads(
@Args('workspaceId', { type: () => UUIDScalarType }) workspaceId: string,
): Promise<AdminWorkspaceChatThreadDTO[]> {
return this.adminChatService.getWorkspaceChatThreads(workspaceId);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => AdminChatThreadMessagesDTO)
async getAdminChatThreadMessages(
@Args('threadId', { type: () => UUIDScalarType }) threadId: string,
): Promise<AdminChatThreadMessagesDTO> {
return this.adminChatService.getChatThreadMessages(threadId);
}
}
@@ -1,226 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import semver from 'semver';
import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import * as z from 'zod';
import { type ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { type ConfigVariablesGroupDataDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables-group.dto';
import { type ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
import { type UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
import { type VersionInfoDTO } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { type FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type 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 { type ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
@Injectable()
export class AdminPanelService {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly fileUrlService: FileUrlService,
private readonly secureHttpClientService: SecureHttpClientService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
async userLookup(userIdentifier: string): Promise<UserLookup> {
const isEmail = userIdentifier.includes('@');
const normalizedIdentifier = isEmail
? userIdentifier.toLowerCase()
: userIdentifier;
const targetUser = await this.userRepository.findOne({
where: isEmail
? { email: normalizedIdentifier }
: { id: normalizedIdentifier },
relations: {
userWorkspaces: {
workspace: {
workspaceUsers: {
user: true,
},
featureFlags: true,
},
},
},
});
userValidator.assertIsDefinedOrThrow(
targetUser,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT, {
userFriendlyMessage: msg`User not found. Please check the email or ID.`,
}),
);
const allFeatureFlagKeys = Object.values(FeatureFlagKey);
return {
user: {
id: targetUser.id,
email: targetUser.email,
firstName: targetUser.firstName,
lastName: targetUser.lastName,
},
workspaces: targetUser.userWorkspaces.map((userWorkspace) => ({
id: userWorkspace.workspace.id,
name: userWorkspace.workspace.displayName ?? '',
totalUsers: userWorkspace.workspace.workspaceUsers.length,
logo: isDefined(userWorkspace.workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: userWorkspace.workspace.logoFileId,
workspaceId: userWorkspace.workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
allowImpersonation: userWorkspace.workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: userWorkspace.workspace.subdomain,
customDomain: userWorkspace.workspace.customDomain,
isCustomDomainEnabled: userWorkspace.workspace.isCustomDomainEnabled,
}),
users: userWorkspace.workspace.workspaceUsers
.filter((workspaceUser) => isDefined(workspaceUser.user))
.map((workspaceUser) => ({
id: workspaceUser.user.id,
email: workspaceUser.user.email,
firstName: workspaceUser.user.firstName,
lastName: workspaceUser.user.lastName,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
value:
userWorkspace.workspace.featureFlags?.find(
(flag) => flag.key === key,
)?.value ?? false,
})) as FeatureFlagEntity[],
})),
};
}
getConfigVariablesGrouped(): ConfigVariablesDTO {
const rawEnvVars = this.twentyConfigService.getAll();
const groupedData = new Map<ConfigVariablesGroup, ConfigVariableDTO[]>();
for (const [varName, { value, metadata, source }] of Object.entries(
rawEnvVars,
)) {
const { group, description } = metadata;
if (metadata.isHiddenInAdminPanel) {
continue;
}
const envVar: ConfigVariableDTO = {
name: varName,
description,
value: value ?? null,
isSensitive: metadata.isSensitive ?? false,
isEnvOnly: metadata.isEnvOnly ?? false,
type: metadata.type,
options: metadata.options,
source,
};
if (!groupedData.has(group)) {
groupedData.set(group, []);
}
groupedData.get(group)?.push(envVar);
}
const groups: ConfigVariablesGroupDataDTO[] = Array.from(
groupedData.entries(),
)
.filter(
([name]) => !CONFIG_VARIABLES_GROUP_METADATA[name].isHiddenInAdminPanel,
)
.sort((a, b) => {
const positionA = CONFIG_VARIABLES_GROUP_METADATA[a[0]].position;
const positionB = CONFIG_VARIABLES_GROUP_METADATA[b[0]].position;
return positionA - positionB;
})
.map(([name, variables]) => ({
name,
description: CONFIG_VARIABLES_GROUP_METADATA[name].description,
isHiddenOnLoad: CONFIG_VARIABLES_GROUP_METADATA[name].isHiddenOnLoad,
variables: variables.sort((a, b) => a.name.localeCompare(b.name)),
}));
return { groups };
}
getConfigVariable(key: string): ConfigVariableDTO {
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<VersionInfoDTO> {
const currentVersion = this.twentyConfigService.get('APP_VERSION');
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const rawResponse = await httpClient.get<unknown>(
'https://hub.docker.com/v2/repositories/twentycrm/twenty/tags?page_size=100',
);
const response = z
.object({
data: z.object({
results: z.array(z.object({ name: z.string() })),
}),
})
.parse(rawResponse);
const versions = response.data.results
.map((tag) => tag.name)
.filter((name) => name !== 'latest' && semver.valid(name));
if (versions.length === 0) {
return { currentVersion, latestVersion: 'latest' };
}
versions.sort((a, b) => semver.compare(b, a));
const latestVersion = versions[0];
return { currentVersion, latestVersion };
} catch {
return { currentVersion, latestVersion: 'latest' };
}
}
}
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('AdminChatMessagePart')
export class AdminChatMessagePartDTO {
@Field(() => String)
type: string;
@Field(() => String, { nullable: true })
textContent: string | null;
@Field(() => String, { nullable: true })
toolName: string | null;
}
@@ -0,0 +1,23 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AdminChatMessagePartDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-message-part.dto';
import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
// Ensure the enum is registered with GraphQL
import 'src/engine/core-modules/admin-panel/enums/agent-message-role.enum';
@ObjectType('AdminChatMessage')
export class AdminChatMessageDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => AgentMessageRole)
role: AgentMessageRole;
@Field(() => [AdminChatMessagePartDTO])
parts: AdminChatMessagePartDTO[];
@Field(() => Date)
createdAt: Date;
}
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AdminChatMessageDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto';
import { AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
@ObjectType('AdminChatThreadMessages')
export class AdminChatThreadMessagesDTO {
@Field(() => AdminWorkspaceChatThreadDTO)
thread: AdminWorkspaceChatThreadDTO;
@Field(() => [AdminChatMessageDTO])
messages: AdminChatMessageDTO[];
}
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { UserInfoDTO } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
@ObjectType('AdminPanelRecentUser')
export class AdminPanelRecentUserDTO extends UserInfoDTO {
@Field(() => String, { nullable: true })
workspaceName?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
workspaceId?: string | null;
}
@@ -0,0 +1,18 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AdminPanelTopWorkspace')
export class AdminPanelTopWorkspaceDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String)
name: string;
@Field(() => Int)
totalUsers: number;
@Field(() => String)
subdomain: string;
}
@@ -0,0 +1,27 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AdminWorkspaceChatThread')
export class AdminWorkspaceChatThreadDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
title: string | null;
@Field(() => Int)
totalInputTokens: number;
@Field(() => Int)
totalOutputTokens: number;
@Field(() => Int)
conversationSize: number;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
@@ -1,11 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto';
@ObjectType('UserInfo')
class UserInfoDTO {
export class UserInfoDTO {
@Field(() => UUIDScalarType)
id: string;
@@ -17,6 +19,9 @@ class UserInfoDTO {
@Field(() => String, { nullable: true })
lastName?: string;
@Field(() => Date)
createdAt: Date;
}
@ObjectType('WorkspaceInfo')
@@ -36,6 +41,12 @@ class WorkspaceInfoDTO {
@Field(() => Number)
totalUsers: number;
@Field(() => WorkspaceActivationStatus)
activationStatus: WorkspaceActivationStatus;
@Field(() => Date)
createdAt: Date;
@Field(() => WorkspaceUrlsDTO)
workspaceUrls: WorkspaceUrlsDTO;
@@ -0,0 +1,8 @@
import { registerEnumType } from '@nestjs/graphql';
import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
registerEnumType(AgentMessageRole, {
name: 'AgentMessageRole',
description: 'Role of a message in a chat thread',
});
@@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { type AdminChatMessageDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto';
import { type AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
@Injectable()
export class AdminPanelChatService {
constructor(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(AgentChatThreadEntity)
private readonly agentChatThreadRepository: Repository<AgentChatThreadEntity>,
@InjectRepository(AgentMessageEntity)
private readonly agentMessageRepository: Repository<AgentMessageEntity>,
) {}
private async assertWorkspaceAllowsImpersonation(
workspaceId: string,
): Promise<void> {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
select: { id: true, allowImpersonation: true },
});
if (!workspace) {
throw new UserInputError('Workspace not found');
}
if (!workspace.allowImpersonation) {
throw new UserInputError('This workspace has not enabled support access');
}
}
async getWorkspaceChatThreads(
workspaceId: string,
): Promise<AdminWorkspaceChatThreadDTO[]> {
await this.assertWorkspaceAllowsImpersonation(workspaceId);
const threads = await this.agentChatThreadRepository.find({
where: { workspaceId },
order: { updatedAt: 'DESC' },
take: 100,
});
return threads.map((thread) => ({
id: thread.id,
title: thread.title,
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
conversationSize: thread.conversationSize,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
}));
}
async getChatThreadMessages(threadId: string): Promise<{
thread: AdminWorkspaceChatThreadDTO;
messages: AdminChatMessageDTO[];
}> {
const thread = await this.agentChatThreadRepository.findOne({
where: { id: threadId },
});
if (!thread) {
throw new UserInputError('Thread not found');
}
await this.assertWorkspaceAllowsImpersonation(thread.workspaceId);
const messages = await this.agentMessageRepository.find({
where: { threadId },
relations: { parts: true },
order: { createdAt: 'ASC' },
});
return {
thread: {
id: thread.id,
title: thread.title,
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
conversationSize: thread.conversationSize,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
},
messages: messages.map((message) => ({
id: message.id,
role: message.role,
parts: (message.parts ?? [])
.sort((a, b) => a.orderIndex - b.orderIndex)
.map((part) => ({
type: part.type,
textContent: part.textContent,
toolName: part.toolName,
})),
createdAt: message.createdAt,
})),
};
}
}
@@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { type ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { type ConfigVariablesGroupDataDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables-group.dto';
import { type ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
import { type 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 { type 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';
@Injectable()
export class AdminPanelConfigService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
getConfigVariablesGrouped(): ConfigVariablesDTO {
const rawEnvVars = this.twentyConfigService.getAll();
const groupedData = new Map<ConfigVariablesGroup, ConfigVariableDTO[]>();
for (const [varName, { value, metadata, source }] of Object.entries(
rawEnvVars,
)) {
const { group, description } = metadata;
if (metadata.isHiddenInAdminPanel) {
continue;
}
const envVar: ConfigVariableDTO = {
name: varName,
description,
value: value ?? null,
isSensitive: metadata.isSensitive ?? false,
isEnvOnly: metadata.isEnvOnly ?? false,
type: metadata.type,
options: metadata.options,
source,
};
if (!groupedData.has(group)) {
groupedData.set(group, []);
}
groupedData.get(group)?.push(envVar);
}
const groups: ConfigVariablesGroupDataDTO[] = Array.from(
groupedData.entries(),
)
.filter(
([name]) => !CONFIG_VARIABLES_GROUP_METADATA[name].isHiddenInAdminPanel,
)
.sort((a, b) => {
const positionA = CONFIG_VARIABLES_GROUP_METADATA[a[0]].position;
const positionB = CONFIG_VARIABLES_GROUP_METADATA[b[0]].position;
return positionA - positionB;
})
.map(([name, variables]) => ({
name,
description: CONFIG_VARIABLES_GROUP_METADATA[name].description,
isHiddenOnLoad: CONFIG_VARIABLES_GROUP_METADATA[name].isHiddenOnLoad,
variables: variables.sort((a, b) => a.name.localeCompare(b.name)),
}));
return { groups };
}
getConfigVariable(key: string): ConfigVariableDTO {
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,
};
}
}
@@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { type AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto';
import { type AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class AdminPanelStatisticsService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
async getRecentUsers(
searchTerm?: string,
): Promise<AdminPanelRecentUserDTO[]> {
let whereClause = 'u."deletedAt" IS NULL';
const params: unknown[] = [];
if (searchTerm && searchTerm.trim().length > 0) {
const term = `%${searchTerm.trim()}%`;
whereClause += ` AND (u.email ILIKE $1 OR CONCAT(u."firstName", ' ', u."lastName") ILIKE $1 OR u.id::text ILIKE $1)`;
params.push(term);
}
const results = await this.userRepository.manager.query(
`SELECT * FROM (
SELECT DISTINCT ON (u.id) u.id, u.email, u."firstName", u."lastName", u."createdAt",
w."displayName" AS "workspaceName", w.id AS "workspaceId"
FROM core."user" u
LEFT JOIN core."userWorkspace" uw ON uw."userId" = u.id AND uw."deletedAt" IS NULL
LEFT JOIN core.workspace w ON w.id = uw."workspaceId" AND w."deletedAt" IS NULL
WHERE ${whereClause}
ORDER BY u.id, u."createdAt" DESC
) sub
ORDER BY sub."createdAt" DESC
LIMIT 10`,
params,
);
return results.map(
(row: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
createdAt: Date;
workspaceName: string | null;
workspaceId: string | null;
}) => ({
id: row.id,
email: row.email,
firstName: row.firstName || null,
lastName: row.lastName || null,
createdAt: row.createdAt,
workspaceName: row.workspaceName ?? null,
workspaceId: row.workspaceId ?? null,
}),
);
}
async getTopWorkspaces(
searchTerm?: string,
): Promise<AdminPanelTopWorkspaceDTO[]> {
let whereClause = 'w."deletedAt" IS NULL';
const params: unknown[] = [];
if (searchTerm && searchTerm.trim().length > 0) {
const term = `%${searchTerm.trim()}%`;
whereClause += ` AND (w."displayName" ILIKE $1 OR w.subdomain ILIKE $1 OR w.id::text ILIKE $1)`;
params.push(term);
}
const results = await this.workspaceRepository.manager.query(
`SELECT w.id, w."displayName" AS name, w.subdomain, COUNT(uw.id)::int AS "totalUsers"
FROM core.workspace w
LEFT JOIN core."userWorkspace" uw ON uw."workspaceId" = w.id AND uw."deletedAt" IS NULL
WHERE ${whereClause}
GROUP BY w.id
ORDER BY "totalUsers" DESC
LIMIT 10`,
params,
);
return results.map(
(row: {
id: string;
name: string;
subdomain: string;
totalUsers: number;
}) => ({
id: row.id,
name: row.name ?? '',
subdomain: row.subdomain ?? '',
totalUsers: row.totalUsers,
}),
);
}
}
@@ -0,0 +1,189 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class AdminPanelUserLookupService {
constructor(
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly fileUrlService: FileUrlService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@InjectRepository(FeatureFlagEntity)
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {}
async userLookup(userIdentifier: string): Promise<UserLookup> {
const isEmail = userIdentifier.includes('@');
const normalizedIdentifier = isEmail
? userIdentifier.toLowerCase()
: userIdentifier;
const targetUser = await this.userRepository.findOne({
where: isEmail
? { email: normalizedIdentifier }
: { id: normalizedIdentifier },
relations: {
userWorkspaces: {
workspace: {
workspaceUsers: {
user: true,
},
featureFlags: true,
},
},
},
});
userValidator.assertIsDefinedOrThrow(
targetUser,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT, {
userFriendlyMessage: msg`User not found. Please check the email or ID.`,
}),
);
const allFeatureFlagKeys = Object.values(FeatureFlagKey);
return {
user: {
id: targetUser.id,
email: targetUser.email,
firstName: targetUser.firstName,
lastName: targetUser.lastName,
createdAt: targetUser.createdAt,
},
workspaces: targetUser.userWorkspaces.map((userWorkspace) => ({
id: userWorkspace.workspace.id,
name: userWorkspace.workspace.displayName ?? '',
totalUsers: userWorkspace.workspace.workspaceUsers.length,
activationStatus: userWorkspace.workspace.activationStatus,
createdAt: userWorkspace.workspace.createdAt,
logo: isDefined(userWorkspace.workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: userWorkspace.workspace.logoFileId,
workspaceId: userWorkspace.workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
allowImpersonation: userWorkspace.workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: userWorkspace.workspace.subdomain,
customDomain: userWorkspace.workspace.customDomain,
isCustomDomainEnabled: userWorkspace.workspace.isCustomDomainEnabled,
}),
users: userWorkspace.workspace.workspaceUsers
.filter((workspaceUser) => isDefined(workspaceUser.user))
.map((workspaceUser) => ({
id: workspaceUser.user.id,
email: workspaceUser.user.email,
firstName: workspaceUser.user.firstName,
lastName: workspaceUser.user.lastName,
createdAt: workspaceUser.user.createdAt,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
value:
userWorkspace.workspace.featureFlags?.find(
(flag) => flag.key === key,
)?.value ?? false,
})),
})),
};
}
async workspaceLookup(workspaceId: string): Promise<UserLookup> {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Workspace not found. Please check the ID.`,
},
);
}
const [workspaceUsers, featureFlags] = await Promise.all([
this.userWorkspaceRepository.find({
where: { workspaceId },
relations: { user: true },
}),
this.featureFlagRepository.find({
where: { workspaceId },
}),
]);
const allFeatureFlagKeys = Object.values(FeatureFlagKey);
const workspaceInfo = {
id: workspace.id,
name: workspace.displayName ?? '',
totalUsers: workspaceUsers.length,
activationStatus: workspace.activationStatus,
createdAt: workspace.createdAt,
logo: isDefined(workspace.logoFileId)
? this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
})
: undefined,
allowImpersonation: workspace.allowImpersonation,
workspaceUrls: this.workspaceDomainsService.getWorkspaceUrls({
subdomain: workspace.subdomain,
customDomain: workspace.customDomain,
isCustomDomainEnabled: workspace.isCustomDomainEnabled,
}),
users: workspaceUsers
.filter((wu) => isDefined(wu.user))
.map((wu) => ({
id: wu.user.id,
email: wu.user.email,
firstName: wu.user.firstName,
lastName: wu.user.lastName,
createdAt: wu.user.createdAt,
})),
featureFlags: allFeatureFlagKeys.map((key) => ({
key,
value: featureFlags.find((flag) => flag.key === key)?.value ?? false,
})),
};
const firstUser = workspaceUsers.find((wu) => isDefined(wu.user))?.user;
return {
user: {
id: firstUser?.id ?? '',
email: firstUser?.email ?? '',
firstName: firstUser?.firstName,
lastName: firstUser?.lastName,
createdAt: firstUser?.createdAt ?? new Date(),
},
workspaces: [workspaceInfo],
};
}
}
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import semver from 'semver';
import * as z from 'zod';
import { type VersionInfoDTO } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class AdminPanelVersionService {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
async getVersionInfo(): Promise<VersionInfoDTO> {
const currentVersion = this.twentyConfigService.get('APP_VERSION');
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const rawResponse = await httpClient.get<unknown>(
'https://hub.docker.com/v2/repositories/twentycrm/twenty/tags?page_size=100',
);
const response = z
.object({
data: z.object({
results: z.array(z.object({ name: z.string() })),
}),
})
.parse(rawResponse);
const versions = response.data.results
.map((tag) => tag.name)
.filter((name) => name !== 'latest' && semver.valid(name));
if (versions.length === 0) {
return { currentVersion, latestVersion: 'latest' };
}
versions.sort((a, b) => semver.compare(b, a));
const latestVersion = versions[0];
return { currentVersion, latestVersion };
} catch {
return { currentVersion, latestVersion: 'latest' };
}
}
}