diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index e26ca00539..d2e3e73ace 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -415,28 +415,39 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( element={} /> } /> - } /> - } /> } - /> - } - /> - } - /> - } - /> - } - /> + element={ + + } + > + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + { const subscriptionStatus = useSubscriptionStatus(); + const permissionMap = usePermissionFlagMap(); + const isAccountSyncEnabled = + permissionMap[PermissionFlagType.CONNECTED_ACCOUNTS]; const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo( WorkspaceActivationStatus.SUSPENDED, ); @@ -43,8 +52,12 @@ export const InformationBannerWrapper = () => { return ( - - + {isAccountSyncEnabled && ( + + )} + {isAccountSyncEnabled && ( + + )} {displayBillingSubscriptionPausedBanner && ( // TODO: remove this once paused subscriptions are deprecated )} diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts index ffd9be1f9c..14ec4f09cb 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts @@ -3,10 +3,13 @@ import { RecoilRoot, useRecoilState, useSetRecoilState } from 'recoil'; import { v4 } from 'uuid'; import { currentUserState } from '@/auth/states/currentUserState'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; + import { OnboardingStatus, + PermissionFlagType, SubscriptionStatus, } from '~/generated-metadata/graphql'; import { @@ -18,16 +21,21 @@ const renderHooks = ( onboardingStatus: OnboardingStatus, withCurrentBillingSubscription: boolean, withOneWorkspaceMember = true, + permissionFlags = mockedUserData.currentUserWorkspace.permissionFlags, ) => { const { result } = renderHook( () => { const [currentUser, setCurrentUser] = useRecoilState(currentUserState); + const setCurrentUserWorkspace = useSetRecoilState( + currentUserWorkspaceState, + ); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setNextOnboardingStatus = useSetNextOnboardingStatus(); return { currentUser, setCurrentUser, setCurrentWorkspace, + setCurrentUserWorkspace, setNextOnboardingStatus, }; }, @@ -37,6 +45,10 @@ const renderHooks = ( ); act(() => { result.current.setCurrentUser({ ...mockedUserData, onboardingStatus }); + result.current.setCurrentUserWorkspace({ + ...mockedUserData.currentUserWorkspace, + permissionFlags, + }); result.current.setCurrentWorkspace({ ...mockCurrentWorkspace, currentBillingSubscription: withCurrentBillingSubscription @@ -75,6 +87,16 @@ describe('useSetNextOnboardingStatus', () => { expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); }); + it('should skip SyncEmail when account sync is disabled', () => { + const nextOnboardingStatus = renderHooks( + OnboardingStatus.PROFILE_CREATION, + false, + true, + [PermissionFlagType.WORKSPACE_MEMBERS], + ); + expect(nextOnboardingStatus).toEqual(OnboardingStatus.INVITE_TEAM); + }); + it('should set next onboarding status for SyncEmail', () => { const nextOnboardingStatus = renderHooks( OnboardingStatus.SYNC_EMAIL, diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts index 674899cdd2..33653861d6 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts @@ -1,5 +1,7 @@ import { useRecoilCallback, useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + import { type CurrentUser, currentUserState, @@ -9,21 +11,36 @@ import { currentWorkspaceState, } from '@/auth/states/currentWorkspaceState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; -import { isDefined } from 'twenty-shared/utils'; -import { OnboardingStatus } from '~/generated-metadata/graphql'; +import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap'; -const getNextOnboardingStatus = ( - currentUser: CurrentUser | null, - currentWorkspace: CurrentWorkspace | null, - calendarBookingPageId: string | null, -) => { +import { + OnboardingStatus, + PermissionFlagType, +} from '~/generated-metadata/graphql'; + +type GetNextOnboardingStatusArgs = { + currentUser: CurrentUser | null; + currentWorkspace: CurrentWorkspace | null; + calendarBookingPageId: string | null; + isAccountSyncEnabled: boolean; +}; + +const getNextOnboardingStatus = ({ + currentUser, + currentWorkspace, + calendarBookingPageId, + isAccountSyncEnabled, +}: GetNextOnboardingStatusArgs) => { if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) { return OnboardingStatus.PROFILE_CREATION; } if (currentUser?.onboardingStatus === OnboardingStatus.PROFILE_CREATION) { if (currentWorkspace?.workspaceMembersCount === 1) { - return OnboardingStatus.SYNC_EMAIL; + if (isAccountSyncEnabled) { + return OnboardingStatus.SYNC_EMAIL; + } + return OnboardingStatus.INVITE_TEAM; } return OnboardingStatus.COMPLETED; } @@ -48,15 +65,19 @@ export const useSetNextOnboardingStatus = () => { const currentUser = useRecoilValue(currentUserState); const currentWorkspace = useRecoilValue(currentWorkspaceState); const calendarBookingPageId = useRecoilValue(calendarBookingPageIdState); + const permissionMap = usePermissionFlagMap(); + const isAccountSyncEnabled = + permissionMap[PermissionFlagType.CONNECTED_ACCOUNTS]; return useRecoilCallback( ({ set }) => () => { - const nextOnboardingStatus = getNextOnboardingStatus( + const nextOnboardingStatus = getNextOnboardingStatus({ currentUser, currentWorkspace, calendarBookingPageId, - ); + isAccountSyncEnabled, + }); set(currentUserState, (current) => { if (isDefined(current)) { return { @@ -67,6 +88,11 @@ export const useSetNextOnboardingStatus = () => { return current; }); }, - [currentWorkspace, currentUser, calendarBookingPageId], + [ + currentUser, + currentWorkspace, + calendarBookingPageId, + isAccountSyncEnabled, + ], ); }; diff --git a/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx b/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx index c88e7fb0d8..ccd70fb2d3 100644 --- a/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx @@ -4,6 +4,7 @@ import { renderHook } from '@testing-library/react'; import { type ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { type MutableSnapshot, RecoilRoot } from 'recoil'; +import { SettingsPath } from 'twenty-shared/types'; import { type Billing, OnboardingStatus, @@ -78,6 +79,7 @@ describe('useSettingsNavigationItems', () => { [PermissionFlagType.API_KEYS_AND_WEBHOOKS]: false, [PermissionFlagType.ROLES]: false, [PermissionFlagType.SECURITY]: false, + [PermissionFlagType.CONNECTED_ACCOUNTS]: false, })); const { result } = renderHook(() => useSettingsNavigationItems(), { @@ -99,6 +101,7 @@ describe('useSettingsNavigationItems', () => { [PermissionFlagType.API_KEYS_AND_WEBHOOKS]: true, [PermissionFlagType.ROLES]: true, [PermissionFlagType.SECURITY]: true, + [PermissionFlagType.CONNECTED_ACCOUNTS]: true, })); const { result } = renderHook(() => useSettingsNavigationItems(), { @@ -113,6 +116,16 @@ describe('useSettingsNavigationItems', () => { }); it('should show user section items regardless of permissions', () => { + (usePermissionFlagMap as jest.Mock).mockImplementation(() => ({ + [PermissionFlagType.WORKSPACE]: false, + [PermissionFlagType.WORKSPACE_MEMBERS]: false, + [PermissionFlagType.DATA_MODEL]: false, + [PermissionFlagType.API_KEYS_AND_WEBHOOKS]: false, + [PermissionFlagType.ROLES]: false, + [PermissionFlagType.SECURITY]: false, + [PermissionFlagType.CONNECTED_ACCOUNTS]: false, + })); + const { result } = renderHook(() => useSettingsNavigationItems(), { wrapper: Wrapper, }); @@ -120,7 +133,13 @@ describe('useSettingsNavigationItems', () => { const userSection = result.current.find( (section) => section.label === 'User', ); - expect(userSection?.items.length).toBeGreaterThan(0); - expect(userSection?.items.every((item) => !item.isHidden)).toBe(true); + expect( + userSection?.items.filter((item) => !item.isHidden).length, + ).toBeGreaterThan(0); + expect( + userSection?.items + .filter((item) => item.path !== SettingsPath.Accounts) + .every((item) => !item.isHidden), + ).toBe(true); }); }); diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index cab015b94f..7525b2131e 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -99,17 +99,20 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { label: t`Accounts`, path: SettingsPath.Accounts, Icon: IconAt, + isHidden: !permissionMap[PermissionFlagType.CONNECTED_ACCOUNTS], subItems: [ { label: t`Emails`, path: SettingsPath.AccountsEmails, Icon: IconMail, + isHidden: !permissionMap[PermissionFlagType.CONNECTED_ACCOUNTS], indentationLevel: 2, }, { label: t`Calendars`, path: SettingsPath.AccountsCalendars, Icon: IconCalendarEvent, + isHidden: !permissionMap[PermissionFlagType.CONNECTED_ACCOUNTS], indentationLevel: 2, }, ], diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index feaee59b31..6ada3aac14 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -192,7 +192,10 @@ export const mockedUserData: MockedUser = { workspaceMember: mockedWorkspaceMemberData, currentWorkspace: mockCurrentWorkspace, currentUserWorkspace: { - permissionFlags: [PermissionFlagType.WORKSPACE_MEMBERS], + permissionFlags: [ + PermissionFlagType.WORKSPACE_MEMBERS, + PermissionFlagType.CONNECTED_ACCOUNTS, + ], twoFactorAuthenticationMethodSummary: [], objectsPermissions: generatedMockObjectMetadataItems.map((item) => ({ objectMetadataId: item.id, diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts index 1c9f9b0474..954eac2cd7 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts @@ -24,7 +24,6 @@ import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services @MetadataResolver() @UsePipes(ResolverValidationPipe) @UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter) -@UseGuards(SettingsPermissionGuard(PermissionFlagType.WORKSPACE)) export class ImapSmtpCaldavResolver { constructor( private readonly ImapSmtpCaldavConnectionService: ImapSmtpCaldavService, @@ -33,7 +32,10 @@ export class ImapSmtpCaldavResolver { ) {} @Query(() => ConnectedImapSmtpCaldavAccountDTO) - @UseGuards(WorkspaceAuthGuard) + @UseGuards( + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS), + ) async getConnectedImapSmtpCaldavAccount( @Args('id', { type: () => UUIDScalarType }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -60,7 +62,10 @@ export class ImapSmtpCaldavResolver { } @Mutation(() => ImapSmtpCaldavConnectionSuccessDTO) - @UseGuards(WorkspaceAuthGuard) + @UseGuards( + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS), + ) async saveImapSmtpCaldavAccount( @Args('accountOwnerId', { type: () => UUIDScalarType }) accountOwnerId: string, diff --git a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts index 66fd54050b..cfe1a28392 100644 --- a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts +++ b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.module.ts @@ -6,9 +6,17 @@ import { TimelineMessagingResolver } from 'src/engine/core-modules/messaging/tim import { UserModule } from 'src/engine/core-modules/user/user.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ - imports: [WorkspaceDataSourceModule, UserModule, ConnectedAccountModule], + imports: [ + WorkspaceDataSourceModule, + UserModule, + ConnectedAccountModule, + FeatureFlagModule, + PermissionsModule, + ], exports: [], providers: [ TimelineMessagingResolver, diff --git a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts index a2d725fcaa..2bb0a4cc3c 100644 --- a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts @@ -2,6 +2,7 @@ import { UseGuards } from '@nestjs/common'; import { Args, ArgsType, Field, Int, Mutation, Query } from '@nestjs/graphql'; import { Max } from 'class-validator'; +import { PermissionFlagType } from 'twenty-shared/constants'; import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -16,6 +17,7 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service'; @@ -149,6 +151,7 @@ export class TimelineMessagingResolver { return timelineThreads; } + @UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS)) @Mutation(() => Boolean) async dismissReconnectAccountBanner( @AuthUser() user: UserEntity,