Fix connected account permissions (#17598)

Fixes #17411

---------

Co-authored-by: neo773 <neo773@protonmail.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
BOHEUS
2026-02-18 00:05:56 +00:00
committed by GitHub
parent 015ccbf0a7
commit 5ae1d94f23
10 changed files with 158 additions and 45 deletions
@@ -415,28 +415,39 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
element={<SettingsTwoFactorAuthenticationMethod />}
/>
<Route path={SettingsPath.Experience} element={<SettingsExperience />} />
<Route path={SettingsPath.Accounts} element={<SettingsAccounts />} />
<Route path={SettingsPath.NewAccount} element={<SettingsNewAccount />} />
<Route
path={SettingsPath.AccountsConfiguration}
element={<SettingsAccountsConfiguration />}
/>
<Route
path={SettingsPath.AccountsCalendars}
element={<SettingsAccountsCalendars />}
/>
<Route
path={SettingsPath.AccountsEmails}
element={<SettingsAccountsEmails />}
/>
<Route
path={SettingsPath.NewImapSmtpCaldavConnection}
element={<SettingsNewImapSmtpCaldavConnection />}
/>
<Route
path={SettingsPath.EditImapSmtpCaldavConnection}
element={<SettingsEditImapSmtpCaldavConnection />}
/>
element={
<SettingsProtectedRouteWrapper
settingsPermission={PermissionFlagType.CONNECTED_ACCOUNTS}
/>
}
>
<Route path={SettingsPath.Accounts} element={<SettingsAccounts />} />
<Route
path={SettingsPath.NewAccount}
element={<SettingsNewAccount />}
/>
<Route
path={SettingsPath.AccountsConfiguration}
element={<SettingsAccountsConfiguration />}
/>
<Route
path={SettingsPath.AccountsCalendars}
element={<SettingsAccountsCalendars />}
/>
<Route
path={SettingsPath.AccountsEmails}
element={<SettingsAccountsEmails />}
/>
<Route
path={SettingsPath.NewImapSmtpCaldavConnection}
element={<SettingsNewImapSmtpCaldavConnection />}
/>
<Route
path={SettingsPath.EditImapSmtpCaldavConnection}
element={<SettingsEditImapSmtpCaldavConnection />}
/>
</Route>
<Route
element={
<SettingsProtectedRouteWrapper
@@ -1,16 +1,22 @@
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { InformationBannerBillingSubscriptionPaused } from '@/information-banner/components/billing/InformationBannerBillingSubscriptionPaused';
import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod';
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useIsSomeMeteredProductCapReached } from '@/workspace/hooks/useIsSomeMeteredProductCapReached';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
import {
PermissionFlagType,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
const StyledInformationBannerWrapper = styled.div`
position: relative;
@@ -22,6 +28,9 @@ const StyledInformationBannerWrapper = styled.div`
export const InformationBannerWrapper = () => {
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 (
<StyledInformationBannerWrapper>
<InformationBannerReconnectAccountInsufficientPermissions />
<InformationBannerReconnectAccountEmailAliases />
{isAccountSyncEnabled && (
<InformationBannerReconnectAccountInsufficientPermissions />
)}
{isAccountSyncEnabled && (
<InformationBannerReconnectAccountEmailAliases />
)}
{displayBillingSubscriptionPausedBanner && (
<InformationBannerBillingSubscriptionPaused /> // TODO: remove this once paused subscriptions are deprecated
)}
@@ -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,
@@ -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,
],
);
};
@@ -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);
});
});
@@ -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,
},
],
@@ -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,
@@ -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,
@@ -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,
@@ -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,