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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user