Gate the workspace setup AI chat to workspace creators (#23881)

The server already refuses to start a workspace setup chat for anyone
who isn't the workspace creator (`workspace-setup-chat.service.ts`, via
`userWorkspaceService.isWorkspaceCreator`), but nothing on the client
checked that. An invitee finishing onboarding was still routed to
`/workspace-setup`, where the kickoff mutation returned `UNAVAILABLE`
and the effect silently returned — leaving them on a dead-end page with
the onboarding header and an empty chat that never starts.

Exposes `isWorkspaceCreator` on `User` as a resolve field next to
`onboardingStatus`, reusing the existing service method, and gates both
the post-onboarding redirect and the page itself on it. Invitees now
land on the default home page instead.
This commit is contained in:
Raphaël Bosi
2026-08-07 11:15:35 +02:00
committed by GitHub
parent 00b6d651f4
commit 47f82b9121
11 changed files with 66 additions and 6 deletions
@@ -957,6 +957,7 @@ type User {
deletedWorkspaceMembers: [DeletedWorkspaceMember!]
hasPassword: Boolean!
supportUserHash: String
isWorkspaceCreator: Boolean
workspaces: [UserWorkspace!]!
availableWorkspaces: AvailableWorkspaces!
}
@@ -680,6 +680,7 @@ export interface User {
deletedWorkspaceMembers?: DeletedWorkspaceMember[]
hasPassword: Scalars['Boolean']
supportUserHash?: Scalars['String']
isWorkspaceCreator?: Scalars['Boolean']
workspaces: UserWorkspace[]
availableWorkspaces: AvailableWorkspaces
__typename: 'User'
@@ -3898,6 +3899,7 @@ export interface UserGenqlSelection{
deletedWorkspaceMembers?: DeletedWorkspaceMemberGenqlSelection
hasPassword?: boolean | number
supportUserHash?: boolean | number
isWorkspaceCreator?: boolean | number
workspaces?: UserWorkspaceGenqlSelection
availableWorkspaces?: AvailableWorkspacesGenqlSelection
__typename?: boolean | number
@@ -1845,6 +1845,9 @@ export default {
"supportUserHash": [
1
],
"isWorkspaceCreator": [
3
],
"workspaces": [
54
],
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ export type CurrentUser = Pick<
| 'canAccessFullAdminPanel'
| 'canImpersonate'
| 'onboardingStatus'
| 'isWorkspaceCreator'
| 'userVars'
| 'firstName'
| 'lastName'
@@ -36,6 +36,7 @@ type RenderHooksOptions = {
isOnboardingAiChatEnabled?: boolean;
isBookCallOnboardingStepEnabled?: boolean;
isBookCallOnboardingStepPending?: boolean;
isWorkspaceCreator?: boolean;
};
const renderHooks = (
@@ -47,6 +48,7 @@ const renderHooks = (
isOnboardingAiChatEnabled = false,
isBookCallOnboardingStepEnabled = false,
isBookCallOnboardingStepPending = false,
isWorkspaceCreator = true,
}: RenderHooksOptions = {},
) => {
jotaiStore.set(
@@ -92,6 +94,7 @@ const renderHooks = (
result.current.setCurrentUser({
...mockedUserData,
onboardingStatus,
isWorkspaceCreator,
userVars: {
...mockedUserData.userVars,
[ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]:
@@ -375,6 +378,16 @@ describe('useSetNextOnboardingStatus', () => {
expect(shouldOpenAiChatAfterOnboarding).toBe(true);
});
it('should not open the ai chat after onboarding for an invitee', () => {
const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } =
renderHooks(OnboardingStatus.INVITE_TEAM, {
isOnboardingAiChatEnabled: true,
isWorkspaceCreator: false,
});
expect(isWelcomeAnimationVisible).toBe(true);
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
});
it('should still show the welcome animation when the ai chat is disabled', () => {
const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } =
renderHooks(OnboardingStatus.INVITE_TEAM, {
@@ -121,7 +121,7 @@ export const useSetNextOnboardingStatus = () => {
store.set(isWelcomeAnimationVisibleState.atom, true);
store.set(
shouldOpenAiChatAfterOnboardingState.atom,
isOnboardingAiChatEnabled,
isOnboardingAiChatEnabled && currentUser?.isWorkspaceCreator === true,
);
}
}, [
@@ -23,6 +23,7 @@ export const USER_QUERY_FRAGMENT = gql`
canImpersonate
supportUserHash
onboardingStatus
isWorkspaceCreator
workspaceMember {
...WorkspaceMemberQueryFragment
}
@@ -5,6 +5,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
import { AiChatTab } from '@/ai/components/AiChatTab';
import { currentUserState } from '@/auth/states/currentUserState';
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
@@ -40,6 +41,7 @@ const StyledContent = styled.div`
export const WorkspaceSetup = () => {
const { t } = useLingui();
const { defaultHomePagePath } = useDefaultHomePagePath();
const currentUser = useAtomStateValue(currentUserState);
const isOnboardingAiChatEnabled = useAtomStateValue(
isOnboardingAiChatEnabledState,
);
@@ -47,7 +49,7 @@ export const WorkspaceSetup = () => {
shouldOpenAiChatAfterOnboardingState,
);
if (!isOnboardingAiChatEnabled) {
if (!isOnboardingAiChatEnabled || currentUser?.isWorkspaceCreator !== true) {
return <Navigate to={defaultHomePagePath} replace />;
}
@@ -6,6 +6,7 @@ import { type ReactNode } from 'react';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
import { currentUserState } from '@/auth/states/currentUserState';
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import {
@@ -14,6 +15,7 @@ import {
} from '@/ui/utilities/state/jotai/jotaiStore';
import { messages } from '~/locales/generated/en';
import { WorkspaceSetup } from '~/pages/onboarding/WorkspaceSetup';
import { mockedUserData } from '~/testing/mock-data/users';
i18n.load({ [SOURCE_LOCALE]: messages });
i18n.activate(SOURCE_LOCALE);
@@ -69,6 +71,13 @@ const setIsOnboardingAiChatEnabled = (value: boolean) => {
jotaiStore.set(isOnboardingAiChatEnabledState.atom, value);
};
const setIsWorkspaceCreator = (value: boolean) => {
jotaiStore.set(currentUserState.atom, {
...mockedUserData,
isWorkspaceCreator: value,
});
};
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={jotaiStore}>
<I18nProvider i18n={i18n}>{children}</I18nProvider>
@@ -80,6 +89,7 @@ describe('WorkspaceSetup', () => {
sessionStorage.clear();
resetJotaiStore();
mockNavigate.mockClear();
setIsWorkspaceCreator(true);
});
it('should dress the chat for onboarding when the post-onboarding hint is set', () => {
@@ -156,4 +166,14 @@ describe('WorkspaceSetup', () => {
expect(queryByTestId('ai-chat-tab')).not.toBeInTheDocument();
expect(mockNavigate).toHaveBeenCalledWith(defaultHomePagePath);
});
it('should redirect home for an invitee', () => {
setIsOnboardingAiChatEnabled(true);
setIsWorkspaceCreator(false);
const { queryByTestId } = render(<WorkspaceSetup />, { wrapper: Wrapper });
expect(queryByTestId('ai-chat-tab')).not.toBeInTheDocument();
expect(mockNavigate).toHaveBeenCalledWith(defaultHomePagePath);
});
});
@@ -580,6 +580,22 @@ export class UserResolver {
});
}
@ResolveField(() => Boolean, {
nullable: true,
})
async isWorkspaceCreator(
@Parent() user: UserEntity,
@AuthWorkspace({ allowUndefined: true })
workspace: WorkspaceEntity | undefined,
): Promise<boolean | null> {
if (!workspace) return null;
return this.userWorkspaceService.isWorkspaceCreator({
userId: user.id,
workspaceId: workspace.id,
});
}
@ResolveField(() => WorkspaceEntity, {
nullable: true,
})