Fixes - Workspace logo migration (#18035)

- Update migration command to handle case where workspace logo is
originated from workspace email and point to twenty-icons.com
- Update same logic for new workspaces
- Add feature-flag for all newly created workspaces
This commit is contained in:
Etienne
2026-02-18 16:35:48 +01:00
committed by GitHub
parent 3bd431e95d
commit 058489b5cc
11 changed files with 381 additions and 125 deletions
@@ -38,13 +38,14 @@ import { EmailVerificationModule } from 'src/engine/core-modules/email-verificat
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity';
import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module';
@@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
FileModule,
],
controllers: [
GoogleAuthController,
@@ -97,6 +97,9 @@ const createSignInUpServiceForTests = () => {
{
createWorkspaceCustomApplication: jest.fn(),
} as any,
{
uploadWorkspaceLogoFromUrl: jest.fn(),
} as any,
{
createQueryRunner: jest.fn(() => queryRunnerMock),
} as any,
@@ -3,6 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { TWENTY_ICONS_BASE_URL } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository, type DataSource, type QueryRunner } from 'typeorm';
import { v4 } from 'uuid';
@@ -27,6 +28,7 @@ import {
type SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type';
import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@@ -62,6 +64,7 @@ export class SignInUpService {
private readonly metricsService: MetricsService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly fileCorePictureService: FileCorePictureService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -463,21 +466,7 @@ export class SignInUpService {
const shouldGrantServerAdmin = !(await this.hasServerAdmin());
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const isLogoUrlValid = async () => {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const response = await httpClient.get(logoUrl, { timeout: 600 });
return response.status === 200;
} catch {
return false;
}
};
const isWorkEmailFound = isWorkEmail(email);
const logo =
isWorkEmailFound && (await isLogoUrlValid()) ? logoUrl : undefined;
const workspaceId = v4();
const workspaceCustomApplicationId = v4();
@@ -496,7 +485,6 @@ export class SignInUpService {
displayName: '',
inviteHash: v4(),
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
logo,
});
const workspace = await queryRunner.manager.save(
@@ -513,6 +501,26 @@ export class SignInUpService {
queryRunner,
);
if (isWorkEmailFound) {
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const logoFile =
await this.fileCorePictureService.uploadWorkspaceLogoFromUrl({
imageUrl: logoUrl,
workspaceId,
applicationUniversalIdentifier:
customApplication.universalIdentifier,
queryRunner,
});
if (isDefined(logoFile)) {
await queryRunner.manager.update(
WorkspaceEntity,
{ id: workspaceId },
{ logoFileId: logoFile.id },
);
}
}
const isExistingUser = userData.type === 'existingUser';
const user = isExistingUser
? userData.existingUser
@@ -16,11 +16,15 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@UseFilters(
PermissionsGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
@MetadataResolver()
export class FileCorePictureResolver {
constructor(
@@ -64,7 +64,7 @@ export class FileCorePictureService {
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
resourcePath: `${FileFolder.CorePicture}/${finalName}`,
resourcePath: finalName,
mimeType,
fileFolder: FileFolder.CorePicture,
applicationUniversalIdentifier: universalIdentifier,
@@ -182,6 +182,25 @@ export class FileCorePictureService {
});
}
private async fetchImageBufferFromUrl(
imageUrl: string,
): Promise<{ buffer: Buffer; extension: string } | undefined> {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
const type = await FileType.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
return undefined;
}
return { buffer, extension: type.ext };
} catch {
return undefined;
}
}
async uploadWorkspaceMemberProfilePictureFromUrl({
imageUrl,
workspaceId,
@@ -193,18 +212,41 @@ export class FileCorePictureService {
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto | undefined> {
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
const imageData = await this.fetchImageBufferFromUrl(imageUrl);
const type = await FileType.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
return;
if (!isDefined(imageData)) {
return undefined;
}
return this.uploadWorkspaceMemberProfilePicture({
file: buffer,
filename: `avatar.${type.ext}`,
file: imageData.buffer,
filename: `avatar.${imageData.extension}`,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
});
}
async uploadWorkspaceLogoFromUrl({
imageUrl,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
}: {
imageUrl: string;
workspaceId: string;
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileEntity | undefined> {
const imageData = await this.fetchImageBufferFromUrl(imageUrl);
if (!isDefined(imageData)) {
return undefined;
}
return this.uploadCorePicture({
file: imageData.buffer,
filename: `logo.${imageData.extension}`,
workspaceId,
applicationUniversalIdentifier,
queryRunner,