Files
twenty/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
T
Félix Malfait 6a1b28bc12 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>
2026-06-18 17:56:14 +02:00

429 lines
15 KiB
TypeScript

import {
setSessionId,
useEventTracker,
} from '@/analytics/hooks/useEventTracker';
import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { useReturnToPath } from '@/auth/hooks/useReturnToPath';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection';
import { useResetFocusStackToRecordIndex } from '@/object-record/record-index/hooks/useResetFocusStackToRecordIndex';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow';
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { PageFocusId } from '@/types/PageFocusId';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useStore } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import {
matchPath,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import { AppBasePath, AppPath, SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated-metadata/graphql';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { getPageTitleFromPath } from '~/utils/title-utils';
const AUTH_AND_ONBOARDING_PATHS = [
...ONGOING_USER_CREATION_PATHS,
...ONBOARDING_PATHS,
AppPath.ResetPassword,
];
// TODO: break down into smaller functions and / or hooks
// - moved usePageChangeEffectNavigateLocation into dedicated hook
export const PageChangeEffect = () => {
const store = useStore();
const navigate = useNavigate();
const [previousLocation, setPreviousLocation] = useState('');
const location = useLocation();
const pageChangeEffectNavigateLocation =
usePageChangeEffectNavigateLocation();
const eventTracker = useEventTracker();
const { initializeQueryParamState } = useInitializeQueryParamState();
//TODO: refactor useResetTableRowSelection hook to not throw when the argument `recordTableId` is an empty string
// - replace CoreObjectNamePlural.Person
const objectNamePlural =
useParams().objectNamePlural ?? CoreObjectNamePlural.Person;
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectNamePlural,
contextStoreCurrentViewId || '',
);
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordIndexId);
const { deactivateRecordTableRow } = useActiveRecordTableRow(recordIndexId);
const { resetRecordBoardSelection } =
useResetRecordBoardSelection(recordIndexId);
const { deactivateBoardCard } = useActiveRecordBoardCard(recordIndexId);
const { unfocusBoardCard } = useFocusedRecordBoardCard(recordIndexId);
const { executeTasksOnAnyLocationChange } =
useExecuteTasksOnAnyLocationChange();
const isAppEffectRedirectEnabled = useAtomStateValue(
isAppEffectRedirectEnabledState,
);
const { closeSidePanelMenu } = useSidePanelMenu();
const { saveReturnToPath, getReturnToPath, clearReturnToPath } =
useReturnToPath();
const isOnAuthOrOnboardingPage = AUTH_AND_ONBOARDING_PATHS.some((appPath) =>
isMatchingLocation(location, appPath),
);
const closeSidePanelUnlessNotRelevant = useCallback(() => {
const currentPage = store.get(sidePanelPageState.atom);
if (currentPage === SidePanelPages.NavigationMenuItemEdit) {
return;
}
const sidePanelIsAiChat = currentPage === SidePanelPages.AskAI;
if (sidePanelIsAiChat) {
return;
}
closeSidePanelMenu();
}, [closeSidePanelMenu, store]);
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
const { resetFocusStackToRecordIndex } = useResetFocusStackToRecordIndex();
const { openNewRecordTitleCell } = useOpenNewRecordTitleCell();
useEffect(() => {
closeSidePanelUnlessNotRelevant();
}, [location.pathname, closeSidePanelUnlessNotRelevant]);
useEffect(() => {
if (!previousLocation || previousLocation !== location.pathname) {
setPreviousLocation(location.pathname);
executeTasksOnAnyLocationChange();
const newPageLayoutId = getPageLayoutIdForLocation({
location,
store,
});
store.set(currentPageLayoutIdState.atom, newPageLayoutId);
}
}, [location, previousLocation, executeTasksOnAnyLocationChange, store]);
useEffect(() => {
initializeQueryParamState();
if (
isDefined(pageChangeEffectNavigateLocation) &&
isAppEffectRedirectEnabled
) {
if (
pageChangeEffectNavigateLocation === AppPath.SignInUp &&
!isOnAuthOrOnboardingPage
) {
saveReturnToPath(
`${window.location.pathname}${window.location.search}${window.location.hash}`,
);
}
const consumedReturnToPath =
getReturnToPath() === pageChangeEffectNavigateLocation;
navigate(pageChangeEffectNavigateLocation);
if (consumedReturnToPath) {
clearReturnToPath();
}
}
}, [
navigate,
pageChangeEffectNavigateLocation,
initializeQueryParamState,
isAppEffectRedirectEnabled,
isOnAuthOrOnboardingPage,
saveReturnToPath,
getReturnToPath,
clearReturnToPath,
]);
useEffect(() => {
const isLeavingRecordIndexPage = !!matchPath(
AppPath.RecordIndexPage,
previousLocation,
);
if (isLeavingRecordIndexPage) {
if (contextStoreCurrentViewType === ContextStoreViewType.Table) {
resetTableRowSelection();
unfocusRecordTableRow();
deactivateRecordTableRow();
}
if (contextStoreCurrentViewType === ContextStoreViewType.Kanban) {
resetRecordBoardSelection();
deactivateBoardCard();
unfocusBoardCard();
}
}
if (location.pathname === previousLocation) {
return;
}
switch (true) {
case isMatchingLocation(location, AppPath.RecordIndexPage): {
resetFocusStackToRecordIndex();
break;
}
case isMatchingLocation(location, AppPath.RecordShowPage): {
const isNewRecord = location.state?.isNewRecord === true;
const isSidePanelOpen = store.get(isSidePanelOpenedState.atom);
if (!isSidePanelOpen) {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.RecordShowPage,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.RecordShowPage,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
},
});
}
if (
isNewRecord &&
isDefined(location.state?.labelIdentifierFieldName)
) {
openNewRecordTitleCell({
recordId: location.state.objectRecordId,
fieldName: location.state.labelIdentifierFieldName,
});
}
break;
}
case isMatchingLocation(location, AppPath.SignInUp): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.SignInUp,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.SignInUp,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
case isMatchingLocation(location, AppPath.Invite): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.InviteTeam,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.InviteTeam,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
case isMatchingLocation(location, AppPath.CreateProfile): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.CreateProfile,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.CreateProfile,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
case isMatchingLocation(location, AppPath.WorkspaceActivation): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.WorkspaceActivation,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.WorkspaceActivation,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
case isMatchingLocation(location, AppPath.SyncEmails): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.SyncEmail,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.SyncEmail,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
case isMatchingLocation(location, AppPath.InviteTeam): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.InviteTeam,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.InviteTeam,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
case isMatchingLocation(location, AppPath.PlanRequired): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.PlanRequired,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.PlanRequired,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
case location.pathname.startsWith(AppBasePath.Settings): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.Settings,
componentInstance: {
componentType: FocusComponentType.PAGE,
componentInstanceId: PageFocusId.Settings,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
enableGlobalHotkeysConflictingWithKeyboard: false,
},
},
});
break;
}
}
}, [
location,
previousLocation,
contextStoreCurrentViewType,
resetTableRowSelection,
unfocusRecordTableRow,
deactivateRecordTableRow,
resetRecordBoardSelection,
deactivateBoardCard,
unfocusBoardCard,
resetFocusStackToRecordIndex,
resetFocusStackToFocusItem,
openNewRecordTitleCell,
store,
]);
useEffect(() => {
setTimeout(() => {
setSessionId();
eventTracker(AnalyticsType['PAGEVIEW'], {
name: getPageTitleFromPath(location.pathname),
properties: {
pathname: location.pathname,
locale: navigator.language,
userAgent: window.navigator.userAgent,
href: window.location.href,
referrer: document.referrer,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
}, 500);
}, [eventTracker, location.pathname]);
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
useEffect(() => {
if (isCaptchaScriptLoaded && isCaptchaRequiredForPath(location.pathname)) {
requestFreshCaptchaToken();
}
}, [isCaptchaScriptLoaded, location.pathname, requestFreshCaptchaToken]);
return <></>;
};