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:
+84
-100
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+13
@@ -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[];
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
|
||||
|
||||
+8
@@ -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',
|
||||
});
|
||||
+107
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
+91
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+107
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+189
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
+50
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user