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
@@ -301,13 +301,13 @@ export const PageChangeEffect = () => {
});
break;
}
case isMatchingLocation(location, AppPath.CreateWorkspace): {
case isMatchingLocation(location, AppPath.WorkspaceActivation): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.CreateWorkspace,
focusId: PageFocusId.WorkspaceActivation,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.CreateWorkspace,
componentInstanceId: PageFocusId.WorkspaceActivation,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
@@ -48,9 +48,9 @@ const Authorize = lazy(() =>
})),
);
const CreateWorkspace = lazy(() =>
import('~/pages/onboarding/CreateWorkspace').then((module) => ({
default: module.CreateWorkspace,
const WorkspaceActivation = lazy(() =>
import('~/pages/onboarding/WorkspaceActivation').then((module) => ({
default: module.WorkspaceActivation,
})),
);
@@ -148,10 +148,10 @@ export const useCreateAppRouter = (
}
/>
<Route
path={AppPath.CreateWorkspace}
path={AppPath.WorkspaceActivation}
element={
<LazyRoute fallback={null}>
<CreateWorkspace />
<WorkspaceActivation />
</LazyRoute>
}
/>
@@ -78,7 +78,7 @@ export const Logo = ({
{isUsingDefaultLogo ? (
<UndecoratedLink
to={AppPath.SignInUp}
onClick={redirectToDefaultDomain}
onClick={() => redirectToDefaultDomain()}
>
<StyledPrimaryLogo
style={{ backgroundImage: `url(${primaryLogoUrl})` }}
@@ -1,7 +1,7 @@
import { AppPath } from 'twenty-shared/types';
export const ONBOARDING_PATHS = [
AppPath.CreateWorkspace,
AppPath.WorkspaceActivation,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.InviteTeam,
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPLOAD_NEW_WORKSPACE_LOGO = gql`
mutation UploadNewWorkspaceLogo($workspaceId: String!, $file: Upload!) {
uploadNewWorkspaceLogo(workspaceId: $workspaceId, file: $file) {
url
}
}
`;
@@ -32,7 +32,6 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { loginTokenState } from '@/auth/states/loginTokenState';
import {
SignInUpStep,
@@ -77,8 +76,6 @@ export const useAuth = () => {
const { loadCurrentUser } = useLoadCurrentUser();
const apolloClient = useApolloClient();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetAtomState(signInUpStepState);
const { redirect } = useRedirect();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
@@ -131,16 +128,18 @@ export const useAuth = () => {
async (
availableWorkspaces: Parameters<typeof countAvailableWorkspaces>[0],
email: string,
{ newTab = true }: { newTab?: boolean } = {},
) => {
const availableWorkspacesCount =
countAvailableWorkspaces(availableWorkspaces);
if (availableWorkspacesCount === 0) {
if (!isMultiWorkspaceEnabled) {
return await createWorkspace({ newTab });
}
// The in-app "Create Workspace" entry point redirects here with this
// signal so an existing user with workspaces lands on the creation form
// instead of the workspace selection step.
const wantsToCreateNewWorkspace =
new URLSearchParams(window.location.search).get('action') ===
'create-new-workspace';
if (availableWorkspacesCount === 0 || wantsToCreateNewWorkspace) {
await apolloClient.query({
query: GetWorkspaceCreationDefaultsDocument,
});
@@ -166,13 +165,7 @@ export const useAuth = () => {
setSignInUpStep(SignInUpStep.WorkspaceSelection);
},
[
apolloClient,
createWorkspace,
isMultiWorkspaceEnabled,
redirectToWorkspaceDomain,
setSignInUpStep,
],
[apolloClient, redirectToWorkspaceDomain, setSignInUpStep],
);
const handleGetLoginTokenFromCredentials = useCallback(
@@ -264,7 +257,6 @@ export const useAuth = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
},
[
@@ -363,7 +355,6 @@ export const useAuth = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
},
onError: (error) => {
@@ -418,7 +409,6 @@ export const useAuth = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
},
[
@@ -11,7 +11,6 @@ import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboar
import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/internal/SignInUpWithCredentials';
import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle';
import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft';
import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import {
@@ -218,42 +217,38 @@ export const SignInUpGlobalScopeForm = () => {
</StyledWorkspaceContainer>
</StyledOnboardingContentContainer>
)}
{signInUpStep === SignInUpStep.WorkspaceCreation && (
<SignInUpWorkspaceCreationForm />
{signInUpStep !== SignInUpStep.WorkspaceSelection && (
<StyledOnboardingContentContainer>
{authProviders.google && (
<SignInUpWithGoogle
action="list-available-workspaces"
isGlobalScope
/>
)}
{authProviders.microsoft && (
<SignInUpWithMicrosoft
action="list-available-workspaces"
isGlobalScope
/>
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<SignInUpWithCredentials isGlobalScope />
</FormProvider>
{signInUpStep === SignInUpStep.Password && (
<StyledForgotPasswordLinkContainer>
<ClickToActionLink
onClick={handleResetPassword(form.getValues('email'))}
>
<Trans>Forgot your password?</Trans>
</ClickToActionLink>
</StyledForgotPasswordLinkContainer>
)}
</StyledOnboardingContentContainer>
)}
{signInUpStep !== SignInUpStep.WorkspaceSelection &&
signInUpStep !== SignInUpStep.WorkspaceCreation && (
<StyledOnboardingContentContainer>
{authProviders.google && (
<SignInUpWithGoogle
action="list-available-workspaces"
isGlobalScope
/>
)}
{authProviders.microsoft && (
<SignInUpWithMicrosoft
action="list-available-workspaces"
isGlobalScope
/>
)}
{(authProviders.google || authProviders.microsoft) && (
<HorizontalSeparator />
)}
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
<SignInUpWithCredentials isGlobalScope />
</FormProvider>
{signInUpStep === SignInUpStep.Password && (
<StyledForgotPasswordLinkContainer>
<ClickToActionLink
onClick={handleResetPassword(form.getValues('email'))}
>
<Trans>Forgot your password?</Trans>
</ClickToActionLink>
</StyledForgotPasswordLinkContainer>
)}
</StyledOnboardingContentContainer>
)}
</>
);
};
@@ -23,7 +23,6 @@ export const SignInUpGlobalScopeFormEffect = () => {
await navigateAfterMultiWorkspaceSignInUp(
user.availableWorkspaces,
user.email,
{ newTab: false },
);
};
@@ -2,14 +2,17 @@ import { SubTitle } from '@/auth/components/SubTitle';
import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { useWorkspaceSubdomainField } from '@/auth/sign-in-up/hooks/useWorkspaceSubdomainField';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
@@ -47,8 +50,15 @@ export const SignInUpWorkspaceCreationForm = () => {
const { t } = useLingui();
const { createWorkspace } = useSignUpInNewWorkspace();
const { frontDomain } = useAtomStateValue(domainConfigurationState);
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [logo, setLogo] = useState<File | undefined>(undefined);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
undefined,
);
const {
workspaceName,
@@ -60,10 +70,37 @@ export const SignInUpWorkspaceCreationForm = () => {
handleWorkspaceNameChange,
handleSubdomainChange,
applySuggestion,
} = useWorkspaceSubdomainField();
} = useWorkspaceSubdomainField({
isSubdomainEnabled: isMultiWorkspaceEnabled,
});
const isContinueDisabled =
workspaceName.trim() === '' || !isAvailable || isSubmitting;
workspaceName.trim() === '' ||
isSubmitting ||
(isMultiWorkspaceEnabled && !isAvailable);
const handleLogoUpload = (file: File) => {
if (!isDefined(file)) {
return;
}
setLogo(file);
setLogoPreviewUrl(URL.createObjectURL(file));
};
const handleLogoRemove = () => {
setLogo(undefined);
setLogoPreviewUrl(undefined);
};
useEffect(() => {
if (!isDefined(logoPreviewUrl)) {
return;
}
return () => {
URL.revokeObjectURL(logoPreviewUrl);
};
}, [logoPreviewUrl]);
const handleSubmit = async () => {
if (isContinueDisabled) {
@@ -74,8 +111,8 @@ export const SignInUpWorkspaceCreationForm = () => {
try {
await createWorkspace({
displayName: workspaceName.trim(),
subdomain,
newTab: false,
...(isMultiWorkspaceEnabled ? { subdomain } : {}),
logo,
});
} finally {
setIsSubmitting(false);
@@ -104,8 +141,18 @@ export const SignInUpWorkspaceCreationForm = () => {
return (
<StyledOnboardingContentContainer>
<SubTitle>
{t`Pick a name and a web address for your new workspace.`}
{isMultiWorkspaceEnabled
? t`Pick a name and a web address for your new workspace.`
: t`Pick a name and a logo for your new workspace.`}
</SubTitle>
<StyledSection>
<InputLabel>{t`Workspace logo`}</InputLabel>
<ImageInput
picture={logoPreviewUrl}
onUpload={handleLogoUpload}
onRemove={handleLogoRemove}
/>
</StyledSection>
<StyledSection>
<TextInput
autoFocus
@@ -117,37 +164,41 @@ export const SignInUpWorkspaceCreationForm = () => {
fullWidth
/>
</StyledSection>
<StyledSection>
<TextInput
label={t`Workspace address`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={status === 'unavailable' || !isDefined(subdomainError)}
fullWidth
/>
{status === 'checking' && <InputHint>{t`Checking…`}</InputHint>}
{status === 'available' && (
<StyledAvailableHint>
{t`This address is available`}
</StyledAvailableHint>
)}
{status === 'unavailable' && (
<StyledUnavailableHint>
{subdomainError}
{isDefined(suggestion) && (
<ClickToActionLink onClick={applySuggestion}>
{t`Use ${suggestion} instead`}
</ClickToActionLink>
)}
</StyledUnavailableHint>
)}
</StyledSection>
{isMultiWorkspaceEnabled && (
<StyledSection>
<TextInput
label={t`Workspace address`}
value={subdomain}
placeholder={t`apple`}
onChange={handleSubdomainChange}
onKeyDown={handleKeyDown}
rightAdornment={
isNonEmptyString(frontDomain) ? `.${frontDomain}` : undefined
}
error={subdomainError}
noErrorHelper={
status === 'unavailable' || !isDefined(subdomainError)
}
fullWidth
/>
{status === 'checking' && <InputHint>{t`Checking…`}</InputHint>}
{status === 'available' && (
<StyledAvailableHint>
{t`This address is available`}
</StyledAvailableHint>
)}
{status === 'unavailable' && (
<StyledUnavailableHint>
{subdomainError}
{isDefined(suggestion) && (
<ClickToActionLink onClick={applySuggestion}>
{t`Use ${suggestion} instead`}
</ClickToActionLink>
)}
</StyledUnavailableHint>
)}
</StyledSection>
)}
<StyledButtonContainer>
<MainButton
title={t`Continue`}
@@ -6,6 +6,7 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { SignInUpWorkspaceCreationForm } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import {
jotaiStore,
resetJotaiStore,
@@ -26,8 +27,15 @@ jest.mock('@/auth/sign-in-up/hooks/useWorkspaceSubdomainField', () => ({
useWorkspaceSubdomainField: () => useWorkspaceSubdomainFieldMock(),
}));
global.URL.createObjectURL = jest.fn(() => 'blob:logo-preview');
global.URL.revokeObjectURL = jest.fn();
dynamicActivate(SOURCE_LOCALE);
const setMultiWorkspaceEnabled = (isEnabled: boolean) => {
jotaiStore.set(isMultiWorkspaceEnabledState.atom, isEnabled);
};
const renderForm = () =>
render(
<JotaiProvider store={jotaiStore}>
@@ -56,73 +64,155 @@ describe('SignInUpWorkspaceCreationForm', () => {
});
});
it('keeps Continue disabled until a workspace name is entered', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: '',
subdomain: '',
status: 'idle',
errorMessage: undefined,
suggestion: undefined,
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
describe('multi-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(true);
});
renderForm();
it('keeps Continue disabled until a workspace name is entered', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: '',
subdomain: '',
status: 'idle',
errorMessage: undefined,
suggestion: undefined,
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
});
renderForm();
it('creates the workspace with the chosen name and address in the same tab', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
newTab: false,
it('creates the workspace with the chosen name and address in the same tab', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
renderForm();
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: undefined,
});
});
it('passes the picked logo file when creating the workspace', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
const { container } = renderForm();
const fileInput = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const logoFile = new File(['logo'], 'logo.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [logoFile] } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
subdomain: 'apple',
logo: logoFile,
});
});
it('routes name edits back through the field hook', () => {
renderForm();
fireEvent.change(screen.getByLabelText('Workspace name'), {
target: { value: 'Acme' },
});
expect(handleWorkspaceNameChangeMock).toHaveBeenCalledWith('Acme');
});
it('offers a one-click suggestion when the address is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
renderForm();
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
fireEvent.click(screen.getByText('Use apple-2 instead'));
expect(applySuggestionMock).toHaveBeenCalledTimes(1);
});
});
it('routes name edits back through the field hook', () => {
renderForm();
fireEvent.change(screen.getByLabelText('Workspace name'), {
target: { value: 'Acme' },
describe('single-workspace', () => {
beforeEach(() => {
setMultiWorkspaceEnabled(false);
});
expect(handleWorkspaceNameChangeMock).toHaveBeenCalledWith('Acme');
});
it('hides the workspace address field', () => {
renderForm();
it('offers a one-click suggestion when the address is taken', () => {
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
expect(screen.getByLabelText('Workspace name')).toBeInTheDocument();
expect(
screen.queryByLabelText('Workspace address'),
).not.toBeInTheDocument();
});
renderForm();
it('enables Continue based on the name only and creates without a subdomain', async () => {
createWorkspaceMock.mockResolvedValue(undefined);
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
// An unavailable subdomain status must not block submission when the
// address field is hidden.
useWorkspaceSubdomainFieldMock.mockReturnValue({
workspaceName: 'Apple',
subdomain: 'apple',
status: 'unavailable',
errorMessage: undefined,
suggestion: 'apple-2',
isAvailable: false,
handleWorkspaceNameChange: handleWorkspaceNameChangeMock,
handleSubdomainChange: handleSubdomainChangeMock,
applySuggestion: applySuggestionMock,
});
fireEvent.click(screen.getByText('Use apple-2 instead'));
renderForm();
expect(applySuggestionMock).toHaveBeenCalledTimes(1);
const continueButton = screen.getByRole('button', { name: 'Continue' });
expect(continueButton).toBeEnabled();
await act(async () => {
fireEvent.click(continueButton);
});
expect(createWorkspaceMock).toHaveBeenCalledWith({
displayName: 'Apple',
logo: undefined,
});
expect(createWorkspaceMock.mock.calls[0][0]).not.toHaveProperty(
'subdomain',
);
});
});
});
@@ -94,6 +94,22 @@ describe('useWorkspaceSubdomainField', () => {
expect(result.current.isAvailable).toBe(true);
});
it('skips availability checks when the subdomain field is disabled', () => {
const { result } = renderHook(
() => useWorkspaceSubdomainField({ isSubdomainEnabled: false }),
{ wrapper: createWrapper([]) },
);
act(() => {
result.current.handleWorkspaceNameChange('Apple');
});
// The name is still tracked, but no subdomain is derived and no lookup runs.
expect(result.current.workspaceName).toBe('Apple');
expect(result.current.subdomain).toBe('');
expect(result.current.status).toBe('idle');
});
it('reports an unavailable manual address and offers a suggestion', async () => {
const { result } = renderHook(() => useWorkspaceSubdomainField(), {
wrapper: createWrapper([
@@ -13,6 +13,7 @@ import { AuthenticatedMethod } from '@/auth/types/AuthenticatedMethod.enum';
import { SignInUpMode } from '@/auth/types/signInUpMode';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useCaptcha } from '@/client-config/hooks/useCaptcha';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useBuildSearchParamsFromUrlSyncedStates } from '@/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -24,6 +25,7 @@ import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { useAuth } from '@/auth/hooks/useAuth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useSignInUp = (form: UseFormReturn<Form>) => {
@@ -33,6 +35,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const [signInUpStep, setSignInUpStep] = useAtomState(signInUpStepState);
const [signInUpMode, setSignInUpMode] = useAtomState(signInUpModeState);
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const { isCaptchaReady } = useCaptcha();
const setLastAuthenticatedMethod = useSetAtomState(
lastAuthenticatedMethodState,
@@ -156,7 +161,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
if (
!isInviteMode &&
signInUpMode === SignInUpMode.SignUp &&
!isOnAWorkspace
(!isOnAWorkspace || !isMultiWorkspaceEnabled)
) {
return await signUpWithCredentials(
data.email.toLowerCase().trim(),
@@ -198,6 +203,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
enqueueErrorSnackBar,
buildSearchParamsFromUrlSyncedStates,
isOnAWorkspace,
isMultiWorkspaceEnabled,
setLastAuthenticatedMethod,
t,
],
@@ -1,43 +1,82 @@
import { useAuth } from '@/auth/hooks/useAuth';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { AppPath } from 'twenty-shared/types';
import { useMutation } from '@apollo/client/react';
import { SignUpInNewWorkspaceDocument } from '~/generated-metadata/graphql';
import {
SignUpInNewWorkspaceDocument,
UploadNewWorkspaceLogoDocument,
} from '~/generated-metadata/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { useLingui } from '@lingui/react/macro';
export const useSignUpInNewWorkspace = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { getAuthTokensFromLoginToken } = useAuth();
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const { enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const [signUpInNewWorkspaceMutation] = useMutation(
SignUpInNewWorkspaceDocument,
);
const [uploadNewWorkspaceLogoMutation] = useMutation(
UploadNewWorkspaceLogoDocument,
);
const createWorkspace = async ({
displayName,
subdomain,
newTab = true,
logo,
}: {
displayName?: string;
subdomain?: string;
newTab?: boolean;
logo?: File;
} = {}) => {
try {
const { data } = await signUpInNewWorkspaceMutation({
variables: { input: { displayName, subdomain } },
});
assertIsDefinedOrThrow(data?.signUpInNewWorkspace);
const workspaceId = data.signUpInNewWorkspace.workspace.id;
if (isDefined(logo)) {
try {
await uploadNewWorkspaceLogoMutation({
variables: { workspaceId, file: logo },
});
} catch (logoUploadError) {
enqueueErrorSnackBar(
CombinedGraphQLErrors.is(logoUploadError)
? { apolloError: logoUploadError }
: {
message:
logoUploadError instanceof Error
? logoUploadError.message
: t`Workspace logo upload failed`,
},
);
}
}
const loginToken = data.signUpInNewWorkspace.loginToken.token;
if (!isMultiWorkspaceEnabled) {
return await getAuthTokensFromLoginToken(loginToken);
}
return await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: data.signUpInNewWorkspace.loginToken.token,
},
newTab ? '_blank' : '_self',
{ loginToken },
'_self',
);
} catch (error) {
enqueueErrorSnackBar(
@@ -21,7 +21,9 @@ export type SubdomainFieldStatus =
const AVAILABILITY_CHECK_DEBOUNCE_MS = 400;
export const useWorkspaceSubdomainField = () => {
export const useWorkspaceSubdomainField = ({
isSubdomainEnabled = true,
}: { isSubdomainEnabled?: boolean } = {}) => {
const apolloClient = useApolloClient();
const subdomainSchema = useMemo(() => getSubdomainValidationSchema(), []);
@@ -118,7 +120,7 @@ export const useWorkspaceSubdomainField = () => {
const handleWorkspaceNameChange = (name: string) => {
setWorkspaceName(name);
if (isManuallyEdited) {
if (!isSubdomainEnabled || isManuallyEdited) {
return;
}
@@ -24,7 +24,7 @@ describe('isValidReturnToPath', () => {
});
it('should return false for onboarding paths', () => {
expect(isValidReturnToPath('/create/workspace')).toBe(false);
expect(isValidReturnToPath('/workspace-activation')).toBe(false);
expect(isValidReturnToPath('/create/profile')).toBe(false);
});
@@ -12,7 +12,10 @@ export const useRedirectToDefaultDomain = () => {
const store = useStore();
const { redirect } = useRedirect();
const redirectToDefaultDomain = () => {
const redirectToDefaultDomain = (options?: {
pathname?: string;
searchParams?: Record<string, string>;
}) => {
const url = new URL(window.location.href);
if (url.hostname !== defaultDomain) {
setLastAuthenticateWorkspaceDomain(null);
@@ -25,6 +28,14 @@ export const useRedirectToDefaultDomain = () => {
url.searchParams.set('returnToPath', returnToPath);
}
if (isNonEmptyString(options?.pathname)) {
url.pathname = options.pathname;
}
Object.entries(options?.searchParams ?? {}).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
url.hostname = defaultDomain;
redirect(url.toString());
}
@@ -21,7 +21,7 @@ export const MinimalMetadataGater = ({ children }: React.PropsWithChildren) => {
isMatchingLocation(location, AppPath.SignInUp) ||
isMatchingLocation(location, AppPath.Invite) ||
isMatchingLocation(location, AppPath.ResetPassword) ||
isMatchingLocation(location, AppPath.CreateWorkspace) ||
isMatchingLocation(location, AppPath.WorkspaceActivation) ||
isMatchingLocation(location, AppPath.PlanRequired) ||
isMatchingLocation(location, AppPath.PlanRequiredSuccess) ||
isMatchingLocation(location, AppPath.Authorize);
@@ -1,6 +1,6 @@
export enum PageFocusId {
Settings = 'settings',
CreateWorkspace = 'create-workspace',
WorkspaceActivation = 'workspace-activation',
SignInUp = 'sign-in-up',
CreateProfile = 'create-profile',
InviteTeam = 'invite-team',
@@ -32,7 +32,7 @@ const testCases = [
{ loc: AppPath.SignInUp, res: true },
{ loc: AppPath.Invite, res: true },
{ loc: AppPath.ResetPassword, res: true },
{ loc: AppPath.CreateWorkspace, res: true },
{ loc: AppPath.WorkspaceActivation, res: true },
{ loc: AppPath.SyncEmails, res: true },
{ loc: AppPath.InviteTeam, res: true },
{ loc: AppPath.PlanRequired, res: true },
@@ -17,7 +17,7 @@ export const useShowAuthModal = () => {
isMatchingLocation(location, AppPath.VerifyEmail) ||
isMatchingLocation(location, AppPath.Verify) ||
isMatchingLocation(location, AppPath.SignInUp) ||
isMatchingLocation(location, AppPath.CreateWorkspace) ||
isMatchingLocation(location, AppPath.WorkspaceActivation) ||
isMatchingLocation(location, AppPath.PlanRequired) ||
isMatchingLocation(location, AppPath.PlanRequiredSuccess) ||
isMatchingLocation(location, AppPath.BookCallDecision) ||
@@ -6,8 +6,8 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
import { supportChatState } from '@/client-config/states/supportChatState';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@@ -21,7 +21,6 @@ import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/s
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -44,11 +43,7 @@ import {
MenuItemSelectAvatar,
UndecoratedLink,
} from 'twenty-ui/navigation';
import { useMutation } from '@apollo/client/react';
import {
type AvailableWorkspace,
SignUpInNewWorkspaceDocument,
} from '~/generated-metadata/graphql';
import { type AvailableWorkspace } from '~/generated-metadata/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
const StyledDescription = styled.div`
@@ -64,19 +59,15 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
const availableWorkspacesCount =
countAvailableWorkspaces(availableWorkspaces);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const { closeDropdown } = useCloseDropdown();
const { signOut } = useAuth();
const { enqueueErrorSnackBar } = useSnackBar();
const { colorScheme, colorSchemeList } = useColorScheme();
const supportChat = useAtomStateValue(supportChatState);
const isSupportChatConfigured =
supportChat?.supportDriver === 'FRONT' &&
isNonEmptyString(supportChat.supportFrontChatId);
const [signUpInNewWorkspaceMutation] = useMutation(
SignUpInNewWorkspaceDocument,
);
const setMultiWorkspaceDropdown = useSetAtomState(
multiWorkspaceDropdownState,
);
@@ -94,23 +85,14 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
);
};
// The workspace name (and logo + subdomain) are collected by the shared
// creation form on the root domain, so we send the user there on the
// WorkspaceCreation step instead of creating a nameless workspace on the fly.
const createWorkspace = () => {
signUpInNewWorkspaceMutation({
onCompleted: async (data) => {
return await redirectToWorkspaceDomain(
getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: data.signUpInNewWorkspace.loginToken.token,
},
'_blank',
);
},
onError: (error) => {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
},
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
redirectToDefaultDomain({
pathname: AppPath.SignInUp,
searchParams: { action: 'create-new-workspace' },
});
};