feat(auth): collect the workspace logo on the sign-up creation step (#21723)

## What & why

A single, consistent **workspace-creation step** for both
multi-workspace and single-workspace self-host — collecting **name +
logo** (and the **subdomain** in multi-workspace) — which **removes the
duplicate name/logo prompt** that previously reappeared on the workspace
subdomain (reported after #21641).

## Changes

**One creation form for both modes**
- With 0 workspaces, both multi-workspace and single-workspace route to
the shared `SignInUpWorkspaceCreationForm`; `SignInUp` renders it for
the `WorkspaceCreation` step regardless of domain/scope.
- The subdomain field shows only in multi-workspace; single-workspace
keeps its fixed address.

**Logo on the creation step**
- New scoped `uploadNewWorkspaceLogo(workspaceId, file)` mutation: the
creator sets a logo on their just-created `PENDING_CREATION` workspace
via the workspace-agnostic token (membership enforced — only the creator
is a member at that point), reusing `uploadWorkspacePicture`. Upload
size is capped via `settings.storage.maxFileSize` (also applied to the
existing logo / profile-picture uploads).
- The picked file is held locally (object-URL preview, revoked on
unmount) and uploaded right after creation (non-fatal on failure).

**Onboarding step → pure activation loader**
- The old "Create your workspace" form (name + logo) is removed. The
onboarding step now activates the pending workspace on mount and shows
the loader, with a **Retry** action on failure.

## Testing
- typecheck (front + server) ; oxlint + oxfmt clean on changed files 
- Unit tests: `auth.resolver.spec`, `useWorkspaceSubdomainField`,
`SignInUpWorkspaceCreationForm` (multi + single-workspace), `useAuth` 
- Metadata GraphQL + `twenty-client-sdk` schema regenerated.

Follow-up to #21641.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Xw37hR5seiCyWnppG9z4op

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-18 17:56:14 +02:00
committed by GitHub
parent 880e7144ec
commit 6a1b28bc12
55 changed files with 1136 additions and 569 deletions
@@ -15,6 +15,7 @@ import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomai
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service';
@@ -77,6 +78,10 @@ describe('AuthResolver', () => {
provide: SubdomainManagerService,
useValue: {},
},
{
provide: FileCorePictureService,
useValue: {},
},
{
provide: UserWorkspaceService,
useValue: {},
@@ -2,6 +2,8 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Context, Mutation, Query } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import bytes from 'bytes';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import omit from 'lodash.omit';
import { PermissionFlagType } from 'twenty-shared/constants';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
@@ -9,7 +11,10 @@ import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { settings } from 'src/engine/constants/settings';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
@@ -57,6 +62,8 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac
import { EmailVerificationExceptionFilter } from 'src/engine/core-modules/email-verification/email-verification-exception-filter.util';
import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
@@ -84,6 +91,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
import { UserAuthGuard } from 'src/engine/guards/user-auth.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';
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthToken } from './dto/auth-token.dto';
@@ -136,6 +144,7 @@ export class AuthResolver {
private readonly eventLogEmitterService: EventLogEmitterService,
private readonly impersonationAuthorizationService: ImpersonationAuthorizationService,
private readonly subdomainManagerService: SubdomainManagerService,
private readonly fileCorePictureService: FileCorePictureService,
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
@@ -557,6 +566,34 @@ export class AuthResolver {
};
}
@Mutation(() => FileWithSignedUrlDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async uploadNewWorkspaceLogo(
@AuthUser() currentUser: AuthContextUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDTO> {
const workspace =
await this.fileCorePictureService.getPendingWorkspaceForLogoUploadOrThrow(
{
userId: currentUser.id,
workspaceId,
},
);
const buffer = await streamToBuffer(
createReadStream(),
bytes(settings.storage.maxFileSize) ?? undefined,
);
return this.fileCorePictureService.uploadWorkspacePicture({
file: buffer,
filename,
workspace,
});
}
@Mutation(() => TransientTokenDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async generateTransientToken(
@@ -951,7 +951,6 @@ export class AuthService {
workspaceInviteHash,
workspaceId,
billingCheckoutSessionState,
action,
locale,
returnToPath,
}: MicrosoftRequest['user'] | GoogleRequest['user'],
@@ -964,11 +963,7 @@ export class AuthService {
// Route SSO sign-ins through the same create-or-select flow as credentials
// instead of landing straight on a workspace subdomain.
if (
!workspaceId &&
!workspaceInviteHash &&
action === 'list-available-workspaces'
) {
if (!workspaceId && !workspaceInviteHash) {
const user =
existingUser ??
(await this.signInUpService.signUpWithoutWorkspace(
@@ -1010,15 +1005,12 @@ export class AuthService {
return url.toString();
}
const currentWorkspace =
action === 'create-new-workspace'
? undefined
: await this.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider,
});
const currentWorkspace = await this.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider,
});
try {
const invitation =
@@ -516,6 +516,18 @@ export class SignInUpService {
await this.assertWorkspaceCreationAllowed(userData);
const displayName = options?.displayName?.trim();
if (!displayName) {
throw new AuthException(
'Workspace name is required',
AuthExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`Workspace name is required`,
},
);
}
const requestedSubdomain = options?.subdomain;
if (isDefined(requestedSubdomain)) {
@@ -544,7 +556,7 @@ export class SignInUpService {
isWorkEmailFound ? { userEmail: email } : {},
),
workspaceCustomApplicationId,
displayName: options?.displayName ?? '',
displayName,
inviteHash: v4(),
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
});