From 2fca60436b9a72f2e62ebb8202838405693ae0fc Mon Sep 17 00:00:00 2001 From: Weiko Date: Tue, 18 Feb 2025 15:50:23 +0100 Subject: [PATCH] Add settingsPermission gate on the frontend (#10179) ## Context With the new permissions system, we now need to hide some items from the settings navigation and gate some routes so they can't be accessed directly. To avoid having to set permission gates in all the component pages, I'm introducing wrapper at the route level and in the Navigation. This is not required and is mostly for pages that are strictly mapped to a single permission, for the rest we still need to use the different hooks manually but it should avoid a bit of boilerplate for most of the cases. - currentUserWorkspaceState to access settingsPermissions - SettingsProtectedRouteWrapper in the router that can take a settingFeature or a featureFlag as a gate logic, if the currentUser does not have access to the settingFeature or the featureFlag is not enabled they will be redirected to the profile page. - SettingsNavigationItemWrapper & SettingsNavigationSectionWrapper. The former will check the same logic as SettingsProtectedRouteWrapper and not display the item if needed. The later will check if all SettingsNavigationItemWrapper are not visible and hide itself if that's the case. - useHasSettingsPermission to get a specific permission state for the current user - useSettingsPermissionMap to get a map of all permissions with their values for the current user - useFeatureFlagsMap same but for featureFlags --- .../modules/apollo/hooks/useApolloFactory.ts | 3 + .../src/modules/app/components/AppRouter.tsx | 18 +- .../modules/app/components/SettingsRoutes.tsx | 62 ++-- .../modules/app/hooks/useCreateAppRouter.tsx | 4 - .../src/modules/auth/hooks/useAuth.ts | 7 + .../auth/states/currentUserWorkspaceState.ts | 10 + .../SettingsNavigationDrawerItem.tsx | 57 ++-- .../SettingsNavigationDrawerItems.tsx | 292 ++++++------------ .../SettingsProtectedRouteWrapper.tsx | 36 +++ .../useSettingsNavigationItems.test.tsx | 110 +++++++ .../hooks/useSettingsNavigationItems.tsx | 194 ++++++++++++ .../roles/hooks/useHasSettingsPermission.ts | 22 ++ .../roles/hooks/useSettingsPermissionMap.ts | 34 ++ .../users/components/UserProviderEffect.tsx | 7 + .../workspace/hooks/useFeatureFlagsMap.ts | 24 ++ .../pages/settings/roles/SettingsRoleEdit.tsx | 66 ++-- .../pages/settings/roles/SettingsRoles.tsx | 17 +- ...leWorkspaceMemberPickerDropdownContent.tsx | 4 +- .../ObjectMetadataItemsDecorator.tsx | 5 +- .../src/testing/mock-data/users.ts | 6 + 20 files changed, 660 insertions(+), 318 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts create mode 100644 packages/twenty-front/src/modules/settings/components/SettingsProtectedRouteWrapper.tsx create mode 100644 packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx create mode 100644 packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx create mode 100644 packages/twenty-front/src/modules/settings/roles/hooks/useHasSettingsPermission.ts create mode 100644 packages/twenty-front/src/modules/settings/roles/hooks/useSettingsPermissionMap.ts create mode 100644 packages/twenty-front/src/modules/workspace/hooks/useFeatureFlagsMap.ts diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index 52995c715..5090f5e0c 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -15,6 +15,7 @@ import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useUpdateEffect } from '~/hooks/useUpdateEffect'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { AppPath } from '@/types/AppPath'; import { ApolloFactory, Options } from '../services/apollo.factory'; @@ -34,6 +35,7 @@ export const useApolloFactory = (options: Partial> = {}) => { const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); + const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); const setWorkspaces = useSetRecoilState(workspacesState); const [, setPreviousUrl] = useRecoilState(previousUrlState); @@ -71,6 +73,7 @@ export const useApolloFactory = (options: Partial> = {}) => { setCurrentUser(null); setCurrentWorkspaceMember(null); setCurrentWorkspace(null); + setCurrentUserWorkspace(null); setWorkspaces([]); if ( !isMatchingLocation(AppPath.Verify) && diff --git a/packages/twenty-front/src/modules/app/components/AppRouter.tsx b/packages/twenty-front/src/modules/app/components/AppRouter.tsx index c68ab8673..0fd81abd4 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouter.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouter.tsx @@ -1,35 +1,19 @@ import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter'; import { currentUserState } from '@/auth/states/currentUserState'; -import { billingState } from '@/client-config/states/billingState'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { RouterProvider } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; -import { FeatureFlagKey } from '~/generated-metadata/graphql'; export const AppRouter = () => { - const billing = useRecoilValue(billingState); - // We want to disable serverless function settings but keep the code for now const isFunctionSettingsEnabled = false; - const isBillingPageEnabled = billing?.isBillingEnabled; - const currentUser = useRecoilValue(currentUserState); const isAdminPageEnabled = currentUser?.canImpersonate; - const isPermissionsEnabled = useIsFeatureEnabled( - FeatureFlagKey.IsPermissionsEnabled, - ); - return ( ); }; diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index f2514b5ed..04b7e7a73 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -1,8 +1,11 @@ import { lazy, Suspense } from 'react'; import { Route, Routes } from 'react-router-dom'; +import { SettingsProtectedRouteWrapper } from '@/settings/components/SettingsProtectedRouteWrapper'; import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader'; import { SettingsPath } from '@/types/SettingsPath'; +import { SettingsFeatures } from 'twenty-shared'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; const SettingsAccountsCalendars = lazy(() => import('~/pages/settings/accounts/SettingsAccountsCalendars').then( @@ -264,17 +267,13 @@ const SettingsRoleEdit = lazy(() => ); type SettingsRoutesProps = { - isBillingEnabled?: boolean; isFunctionSettingsEnabled?: boolean; isAdminPageEnabled?: boolean; - isPermissionsEnabled?: boolean; }; export const SettingsRoutes = ({ - isBillingEnabled, isFunctionSettingsEnabled, isAdminPageEnabled, - isPermissionsEnabled, }: SettingsRoutesProps) => ( }> @@ -290,35 +289,50 @@ export const SettingsRoutes = ({ path={SettingsPath.AccountsEmails} element={} /> - {isBillingEnabled && ( + + } + > } /> - )} + } /> } /> } /> - } /> - } /> } - /> - } - /> - } /> - {isPermissionsEnabled && ( - <> - } /> - } + element={ + - - )} + } + > + } /> + } + /> + } + /> + } /> + + + } + > + } /> + } /> + } /> createBrowserRouter( createRoutesFromElements( @@ -61,10 +59,8 @@ export const useCreateAppRouter = ( path={AppPath.SettingsCatchAll} element={ } /> diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index c170dc90c..2db93f59a 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -44,6 +44,7 @@ import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTi import { currentUserState } from '../states/currentUserState'; import { tokenPairState } from '../states/tokenPairState'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { SignInUpStep, signInUpStepState, @@ -71,6 +72,7 @@ export const useAuth = () => { const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); + const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); const setIsAppWaitingForFreshObjectMetadataState = useSetRecoilState( isAppWaitingForFreshObjectMetadataState, ); @@ -255,6 +257,10 @@ export const useAuth = () => { setCurrentWorkspaceMembers(workspaceMembers); } + if (isDefined(user.currentUserWorkspace)) { + setCurrentUserWorkspace(user.currentUserWorkspace); + } + if (isDefined(user.workspaceMember)) { workspaceMember = { ...user.workspaceMember, @@ -318,6 +324,7 @@ export const useAuth = () => { getCurrentUser, isOnAWorkspace, setCurrentUser, + setCurrentUserWorkspace, setCurrentWorkspace, setCurrentWorkspaceMember, setCurrentWorkspaceMembers, diff --git a/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts new file mode 100644 index 000000000..4b72f52bf --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts @@ -0,0 +1,10 @@ +import { createState } from '@ui/utilities/state/utils/createState'; +import { UserWorkspace } from '~/generated/graphql'; + +export type CurrentUserWorkspace = Pick; + +export const currentUserWorkspaceState = + createState({ + key: 'currentUserWorkspaceState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx index 8d31605a9..2eb6c7dd0 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItem.tsx @@ -1,48 +1,57 @@ import { useMatch, useResolvedPath } from 'react-router-dom'; -import { SettingsPath } from '@/types/SettingsPath'; -import { - NavigationDrawerItem, - NavigationDrawerItemProps, -} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; +import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; +import { SettingsNavigationItem } from '@/settings/hooks/useSettingsNavigationItems'; +import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerSubItemState'; +import { isDefined } from 'twenty-shared'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -type SettingsNavigationDrawerItemProps = Pick< - NavigationDrawerItemProps, - 'Icon' | 'label' | 'indentationLevel' | 'soon' -> & { - matchSubPages?: boolean; - path: SettingsPath; +type SettingsNavigationDrawerItemProps = { + item: SettingsNavigationItem; subItemState?: NavigationDrawerSubItemState; }; export const SettingsNavigationDrawerItem = ({ - Icon, - label, - indentationLevel, - matchSubPages = true, - path, - soon, + item, subItemState, }: SettingsNavigationDrawerItemProps) => { - const href = getSettingsPath(path); + const href = getSettingsPath(item.path); const pathName = useResolvedPath(href).pathname; - const isActive = !!useMatch({ path: pathName, - end: !matchSubPages, + end: !item.matchSubPages, }); + if (isDefined(item.isHidden) && item.isHidden) { + return null; + } + + if (isDefined(item.isAdvanced) && item.isAdvanced) { + return ( + + + + ); + } + return ( ); }; diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 11cfb7dec..1190b8195 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -1,229 +1,121 @@ -import { useRecoilValue } from 'recoil'; -import { - IconApps, - IconAt, - IconCalendarEvent, - IconCode, - IconColorSwatch, - IconComponent, - IconCurrencyDollar, - IconDoorEnter, - IconFlask, - IconFunction, - IconHierarchy2, - IconKey, - IconLock, - IconMail, - IconRocket, - IconServer, - IconSettings, - IconUserCircle, - IconUsers, -} from 'twenty-ui'; +import { IconDoorEnter } from 'twenty-ui'; import { useAuth } from '@/auth/hooks/useAuth'; -import { currentUserState } from '@/auth/states/currentUserState'; -import { billingState } from '@/client-config/states/billingState'; -import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem'; -import { SettingsPath } from '@/types/SettingsPath'; import { - NavigationDrawerItem, - NavigationDrawerItemIndentationLevel, -} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; + SettingsNavigationItem, + SettingsNavigationSection, + useSettingsNavigationItems, +} from '@/settings/hooks/useSettingsNavigationItems'; +import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useLingui } from '@lingui/react/macro'; import { matchPath, resolvePath, useLocation } from 'react-router-dom'; -import { FeatureFlagKey } from '~/generated/graphql'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -type SettingsNavigationItem = { - label: string; - path: SettingsPath; - Icon: IconComponent; - indentationLevel?: NavigationDrawerItemIndentationLevel; - matchSubPages?: boolean; -}; - export const SettingsNavigationDrawerItems = () => { const { signOut } = useAuth(); - const { t } = useLingui(); - const billing = useRecoilValue(billingState); - const isPermissionsEnabled = useIsFeatureEnabled( - FeatureFlagKey.IsPermissionsEnabled, - ); + const settingsNavigationItems: SettingsNavigationSection[] = + useSettingsNavigationItems(); - // We want to disable this serverless function setting menu but keep the code - // for now - const isFunctionSettingsEnabled = false; - - const isBillingPageEnabled = billing?.isBillingEnabled; - - const currentUser = useRecoilValue(currentUserState); - const isAdminPageEnabled = currentUser?.canImpersonate; - const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState); - // TODO: Refactor this part to only have arrays of navigation items const currentPathName = useLocation().pathname; - const accountSubSettings: SettingsNavigationItem[] = [ - { - label: t`Emails`, - path: SettingsPath.AccountsEmails, - Icon: IconMail, - indentationLevel: 2, - }, - { - label: t`Calendars`, - path: SettingsPath.AccountsCalendars, - Icon: IconCalendarEvent, - indentationLevel: 2, - }, - ]; + const getSelectedIndexForSubItems = (subItems: SettingsNavigationItem[]) => { + return subItems.findIndex((subItem) => { + const href = getSettingsPath(subItem.path); + const pathName = resolvePath(href).pathname; - const selectedIndex = accountSubSettings.findIndex((accountSubSetting) => { - const href = getSettingsPath(accountSubSetting.path); - const pathName = resolvePath(href).pathname; - - return matchPath( - { - path: pathName, - end: accountSubSetting.matchSubPages === false, - }, - currentPathName, - ); - }); + return matchPath( + { + path: pathName, + end: subItem.matchSubPages === false, + }, + currentPathName, + ); + }); + }; return ( <> - - - - - - - {accountSubSettings.map((navigationItem, index) => ( - - ))} - - - - - - - {isBillingPageEnabled && ( - - )} - {isPermissionsEnabled && ( - - )} - - - - - - + {settingsNavigationItems.map((section) => { + const allItemsHidden = section.items.every((item) => item.isHidden); + if (allItemsHidden) { + return null; + } + return ( + + {section.isAdvanced ? ( + + + + ) : ( + + )} + {section.items.map((item, index) => { + const subItems = item.subItems; + if (Array.isArray(subItems) && subItems.length > 0) { + const selectedSubItemIndex = + getSelectedIndexForSubItems(subItems); + + return ( + + + {subItems.map((subItem, subIndex) => ( + + ))} + + ); + } + return ( + + ); + })} + + ); + })} - - - - - - - {isFunctionSettingsEnabled && ( - - - - )} - - - - {isAdminPageEnabled && ( - - )} - {labPublicFeatureFlags?.length > 0 && ( - - )} - { + const isPermissionsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IsPermissionsEnabled, + ); + const hasPermission = useHasSettingsPermission(settingsPermission); + const requiredFeatureFlagEnabled = useIsFeatureEnabled( + requiredFeatureFlag || null, + ); + + if ( + (requiredFeatureFlag && !requiredFeatureFlagEnabled) || + (!hasPermission && isPermissionsEnabled) + ) { + return ; + } + + return children ?? ; +}; 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 new file mode 100644 index 000000000..82a99aba2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx @@ -0,0 +1,110 @@ +import { useSettingsNavigationItems } from '@/settings/hooks/useSettingsNavigationItems'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { MutableSnapshot, RecoilRoot } from 'recoil'; +import { SettingsFeatures } from 'twenty-shared'; +import { Billing, FeatureFlagKey, OnboardingStatus } from '~/generated/graphql'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { billingState } from '@/client-config/states/billingState'; +import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; +import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap'; + +const mockCurrentUser = { + id: 'fake-user-id', + email: 'fake@email.com', + supportUserHash: null, + analyticsTinybirdJwts: null, + canImpersonate: false, + onboardingStatus: OnboardingStatus.COMPLETED, + userVars: {}, +}; + +const mockBilling: Billing = { + isBillingEnabled: false, + billingUrl: '', + trialPeriods: [], + __typename: 'Billing', +}; + +const initializeState = ({ set }: MutableSnapshot) => { + set(currentUserState, mockCurrentUser); + set(billingState, mockBilling); + set(labPublicFeatureFlagsState, []); +}; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +jest.mock('@/settings/roles/hooks/useSettingsPermissionMap', () => ({ + useSettingsPermissionMap: jest.fn(), +})); + +jest.mock('@/workspace/hooks/useFeatureFlagsMap', () => ({ + useFeatureFlagsMap: () => ({ + [FeatureFlagKey.IsPermissionsEnabled]: true, + }), +})); + +describe('useSettingsNavigationItems', () => { + it('should hide workspace settings when no permissions', () => { + (useSettingsPermissionMap as jest.Mock).mockImplementation(() => ({ + [SettingsFeatures.WORKSPACE]: false, + [SettingsFeatures.WORKSPACE_USERS]: false, + [SettingsFeatures.DATA_MODEL]: false, + [SettingsFeatures.API_KEYS_AND_WEBHOOKS]: false, + [SettingsFeatures.ROLES]: false, + [SettingsFeatures.SECURITY]: false, + })); + + const { result } = renderHook(() => useSettingsNavigationItems(), { + wrapper: Wrapper, + }); + + const workspaceSection = result.current.find( + (section) => section.label === 'Workspace', + ); + + expect(workspaceSection?.items.every((item) => item.isHidden)).toBe(true); + }); + + it('should show workspace settings when has permissions', () => { + (useSettingsPermissionMap as jest.Mock).mockImplementation(() => ({ + [SettingsFeatures.WORKSPACE]: true, + [SettingsFeatures.WORKSPACE_USERS]: true, + [SettingsFeatures.DATA_MODEL]: true, + [SettingsFeatures.API_KEYS_AND_WEBHOOKS]: true, + [SettingsFeatures.ROLES]: true, + [SettingsFeatures.SECURITY]: true, + })); + + const { result } = renderHook(() => useSettingsNavigationItems(), { + wrapper: Wrapper, + }); + + const workspaceSection = result.current.find( + (section) => section.label === 'Workspace', + ); + + expect(workspaceSection?.items.some((item) => !item.isHidden)).toBe(true); + }); + + it('should show user section items regardless of permissions', () => { + const { result } = renderHook(() => useSettingsNavigationItems(), { + wrapper: Wrapper, + }); + + const userSection = result.current.find( + (section) => section.label === 'User', + ); + expect(userSection?.items.length).toBeGreaterThan(0); + expect(userSection?.items.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 new file mode 100644 index 000000000..3dbc4e606 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -0,0 +1,194 @@ +import { + IconApps, + IconAt, + IconCalendarEvent, + IconCode, + IconColorSwatch, + IconComponent, + IconCurrencyDollar, + IconFlask, + IconFunction, + IconHierarchy2, + IconKey, + IconLock, + IconMail, + IconRocket, + IconServer, + IconSettings, + IconUserCircle, + IconUsers, +} from 'twenty-ui'; + +import { SettingsPath } from '@/types/SettingsPath'; +import { SettingsFeatures } from 'twenty-shared'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { billingState } from '@/client-config/states/billingState'; +import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; +import { useSettingsPermissionMap } from '@/settings/roles/hooks/useSettingsPermissionMap'; +import { NavigationDrawerItemIndentationLevel } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; +import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap'; +import { t } from '@lingui/core/macro'; +import { useRecoilValue } from 'recoil'; + +export type SettingsNavigationSection = { + label: string; + items: SettingsNavigationItem[]; + isAdvanced?: boolean; +}; + +export type SettingsNavigationItem = { + label: string; + path: SettingsPath; + Icon: IconComponent; + indentationLevel?: NavigationDrawerItemIndentationLevel; + matchSubPages?: boolean; + isHidden?: boolean; + subItems?: SettingsNavigationItem[]; + isAdvanced?: boolean; + soon?: boolean; +}; + +export const useSettingsNavigationItems = (): SettingsNavigationSection[] => { + const billing = useRecoilValue(billingState); + + const isFunctionSettingsEnabled = false; + const isBillingEnabled = billing?.isBillingEnabled ?? false; + const currentUser = useRecoilValue(currentUserState); + const isAdminEnabled = currentUser?.canImpersonate ?? false; + const labPublicFeatureFlags = useRecoilValue(labPublicFeatureFlagsState); + + const featureFlags = useFeatureFlagsMap(); + const permissionMap = useSettingsPermissionMap(); + + return [ + { + label: t`User`, + items: [ + { + label: t`Profile`, + path: SettingsPath.ProfilePage, + Icon: IconUserCircle, + }, + { + label: t`Experience`, + path: SettingsPath.Experience, + Icon: IconColorSwatch, + }, + { + label: t`Accounts`, + path: SettingsPath.Accounts, + Icon: IconAt, + matchSubPages: false, + subItems: [ + { + label: t`Emails`, + path: SettingsPath.AccountsEmails, + Icon: IconMail, + indentationLevel: 2, + }, + { + label: t`Calendars`, + path: SettingsPath.AccountsCalendars, + Icon: IconCalendarEvent, + indentationLevel: 2, + }, + ], + }, + ], + }, + { + label: t`Workspace`, + items: [ + { + label: t`General`, + path: SettingsPath.Workspace, + Icon: IconSettings, + isHidden: !permissionMap[SettingsFeatures.WORKSPACE], + }, + { + label: t`Members`, + path: SettingsPath.WorkspaceMembersPage, + Icon: IconUsers, + isHidden: !permissionMap[SettingsFeatures.WORKSPACE_USERS], + }, + { + label: t`Billing`, + path: SettingsPath.Billing, + Icon: IconCurrencyDollar, + isHidden: !isBillingEnabled, + }, + { + label: t`Roles`, + path: SettingsPath.Roles, + Icon: IconLock, + isHidden: + !featureFlags[FeatureFlagKey.IsPermissionsEnabled] || + !permissionMap[SettingsFeatures.ROLES], + }, + { + label: t`Data model`, + path: SettingsPath.Objects, + Icon: IconHierarchy2, + isHidden: !permissionMap[SettingsFeatures.DATA_MODEL], + }, + { + label: t`Integrations`, + path: SettingsPath.Integrations, + Icon: IconApps, + isHidden: !permissionMap[SettingsFeatures.API_KEYS_AND_WEBHOOKS], + }, + { + label: t`Security`, + path: SettingsPath.Security, + Icon: IconKey, + isAdvanced: true, + isHidden: !permissionMap[SettingsFeatures.SECURITY], + }, + ], + }, + { + label: t`Developers`, + isAdvanced: true, + items: [ + { + label: t`API & Webhooks`, + path: SettingsPath.Developers, + Icon: IconCode, + isAdvanced: true, + isHidden: !permissionMap[SettingsFeatures.API_KEYS_AND_WEBHOOKS], + }, + { + label: t`Functions`, + path: SettingsPath.ServerlessFunctions, + Icon: IconFunction, + isHidden: !isFunctionSettingsEnabled, + isAdvanced: true, + }, + ], + }, + { + label: t`Other`, + items: [ + { + label: t`Server Admin`, + path: SettingsPath.AdminPanel, + Icon: IconServer, + isHidden: !isAdminEnabled, + }, + { + label: t`Lab`, + path: SettingsPath.Lab, + Icon: IconFlask, + isHidden: !labPublicFeatureFlags.length, + }, + { + label: t`Releases`, + path: SettingsPath.Releases, + Icon: IconRocket, + }, + ], + }, + ]; +}; diff --git a/packages/twenty-front/src/modules/settings/roles/hooks/useHasSettingsPermission.ts b/packages/twenty-front/src/modules/settings/roles/hooks/useHasSettingsPermission.ts new file mode 100644 index 000000000..222a8611f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/hooks/useHasSettingsPermission.ts @@ -0,0 +1,22 @@ +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { useRecoilValue } from 'recoil'; +import { SettingsFeatures } from 'twenty-shared'; + +export const useHasSettingsPermission = ( + settingsPermission?: SettingsFeatures, +) => { + const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState); + + if (!settingsPermission) { + return true; + } + + const currentUserWorkspaceSettingsPermissions = + currentUserWorkspace?.settingsPermissions; + + if (!currentUserWorkspaceSettingsPermissions) { + return false; + } + + return currentUserWorkspaceSettingsPermissions.includes(settingsPermission); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/hooks/useSettingsPermissionMap.ts b/packages/twenty-front/src/modules/settings/roles/hooks/useSettingsPermissionMap.ts new file mode 100644 index 000000000..f15851088 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/hooks/useSettingsPermissionMap.ts @@ -0,0 +1,34 @@ +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { useRecoilValue } from 'recoil'; +import { SettingsFeatures } from 'twenty-shared'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; +import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue'; + +export const useSettingsPermissionMap = (): Record< + SettingsFeatures, + boolean +> => { + const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState); + + const isPermissionEnabled = useIsFeatureEnabled( + FeatureFlagKey.IsPermissionsEnabled, + ); + + const currentUserWorkspaceSettingsPermissions = + currentUserWorkspace?.settingsPermissions; + + const initialPermissions = buildRecordFromKeysWithSameValue( + Object.values(SettingsFeatures), + !isPermissionEnabled, + ); + + if (!currentUserWorkspaceSettingsPermissions) { + return initialPermissions; + } + + return currentUserWorkspaceSettingsPermissions.reduce((acc, permission) => { + acc[permission] = true; + return acc; + }, initialPermissions); +}; diff --git a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx index 80d2afbd1..ff6580a16 100644 --- a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useRecoilState, useSetRecoilState } from 'recoil'; import { currentUserState } from '@/auth/states/currentUserState'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -29,6 +30,7 @@ export const UserProviderEffect = () => { ); const setCurrentUser = useSetRecoilState(currentUserState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); + const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); const setWorkspaces = useSetRecoilState(workspacesState); const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); @@ -58,6 +60,10 @@ export const UserProviderEffect = () => { setCurrentWorkspace(queryData.currentUser.currentWorkspace); } + if (isDefined(queryData.currentUser.currentUserWorkspace)) { + setCurrentUserWorkspace(queryData.currentUser.currentUserWorkspace); + } + const { workspaceMember, workspaceMembers, @@ -115,6 +121,7 @@ export const UserProviderEffect = () => { } }, [ setCurrentUser, + setCurrentUserWorkspace, setCurrentWorkspaceMembers, isLoading, queryLoading, diff --git a/packages/twenty-front/src/modules/workspace/hooks/useFeatureFlagsMap.ts b/packages/twenty-front/src/modules/workspace/hooks/useFeatureFlagsMap.ts new file mode 100644 index 000000000..bb65daa52 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/hooks/useFeatureFlagsMap.ts @@ -0,0 +1,24 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useRecoilValue } from 'recoil'; +import { FeatureFlagKey } from '~/generated/graphql'; +import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue'; + +export const useFeatureFlagsMap = (): Record => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const currentWorkspaceFeatureFlags = currentWorkspace?.featureFlags; + + const initialFeatureFlags = buildRecordFromKeysWithSameValue( + Object.values(FeatureFlagKey), + false, + ); + + if (!currentWorkspaceFeatureFlags) { + return initialFeatureFlags; + } + + return currentWorkspaceFeatureFlags.reduce((acc, featureFlag) => { + acc[featureFlag.key] = featureFlag.value; + return acc; + }, initialFeatureFlags); +}; diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoleEdit.tsx b/packages/twenty-front/src/pages/settings/roles/SettingsRoleEdit.tsx index 25eb7dbcc..8cead7a45 100644 --- a/packages/twenty-front/src/pages/settings/roles/SettingsRoleEdit.tsx +++ b/packages/twenty-front/src/pages/settings/roles/SettingsRoleEdit.tsx @@ -103,39 +103,37 @@ export const SettingsRoleEdit = () => { }; return ( - <> - - - - - } - links={[ - { - children: 'Workspace', - href: getSettingsPath(SettingsPath.Workspace), - }, - { - children: 'Roles', - href: getSettingsPath(SettingsPath.Roles), - }, - { - children: role.label, - }, - ]} - > - - - - {renderActiveTabContent()} - - - - + + + + + } + links={[ + { + children: 'Workspace', + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: 'Roles', + href: getSettingsPath(SettingsPath.Roles), + }, + { + children: role.label, + }, + ]} + > + + + + {renderActiveTabContent()} + + + ); }; diff --git a/packages/twenty-front/src/pages/settings/roles/SettingsRoles.tsx b/packages/twenty-front/src/pages/settings/roles/SettingsRoles.tsx index 87b9810c4..0736c83b4 100644 --- a/packages/twenty-front/src/pages/settings/roles/SettingsRoles.tsx +++ b/packages/twenty-front/src/pages/settings/roles/SettingsRoles.tsx @@ -20,9 +20,9 @@ import { Table } from '@/ui/layout/table/components/Table'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableRow } from '@/ui/layout/table/components/TableRow'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useTheme } from '@emotion/react'; -import { FeatureFlagKey, useGetRolesQuery } from '~/generated/graphql'; +import React from 'react'; +import { useGetRolesQuery } from '~/generated/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; @@ -90,19 +90,13 @@ const StyledAssignedText = styled.div` export const SettingsRoles = () => { const { t } = useLingui(); - const isPermissionsEnabled = useIsFeatureEnabled( - FeatureFlagKey.IsPermissionsEnabled, - ); + const theme = useTheme(); const navigateSettings = useNavigateSettings(); const { data: rolesData, loading: rolesLoading } = useGetRolesQuery({ fetchPolicy: 'network-only', }); - if (!isPermissionsEnabled) { - return null; - } - const handleRoleClick = (roleId: string) => { navigateSettings(SettingsPath.RoleDetail, { roleId }); }; @@ -157,9 +151,8 @@ export const SettingsRoles = () => { {role.workspaceMembers .slice(0, 5) .map((workspaceMember) => ( - <> + { positionStrategy="fixed" delay={TooltipDelay.shortDelay} /> - + ))} diff --git a/packages/twenty-front/src/pages/settings/roles/components/RoleWorkspaceMemberPickerDropdownContent.tsx b/packages/twenty-front/src/pages/settings/roles/components/RoleWorkspaceMemberPickerDropdownContent.tsx index 3b9a68660..6316191be 100644 --- a/packages/twenty-front/src/pages/settings/roles/components/RoleWorkspaceMemberPickerDropdownContent.tsx +++ b/packages/twenty-front/src/pages/settings/roles/components/RoleWorkspaceMemberPickerDropdownContent.tsx @@ -19,8 +19,8 @@ export const RoleWorkspaceMemberPickerDropdownContent = ({ return null; } - if (!filteredWorkspaceMembers?.length && searchFilter?.length > 0) { - return ; + if (!filteredWorkspaceMembers.length && searchFilter.length > 0) { + return ; } return ( diff --git a/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx b/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx index c1582edba..47b5809ac 100644 --- a/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { currentUserState } from '@/auth/states/currentUserState'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect'; import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider'; @@ -16,11 +17,13 @@ export const ObjectMetadataItemsDecorator: Decorator = (Story) => { currentWorkspaceMemberState, ); const setCurrentUser = useSetRecoilState(currentUserState); + const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); useEffect(() => { setCurrentWorkspaceMember(mockWorkspaceMembers[0]); setCurrentUser(mockedUserData); - }, [setCurrentUser, setCurrentWorkspaceMember]); + setCurrentUserWorkspace(mockedUserData.currentUserWorkspace); + }, [setCurrentUser, setCurrentWorkspaceMember, setCurrentUserWorkspace]); return ( <> diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index d50a7cda5..b279adec7 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -1,7 +1,9 @@ +import { CurrentUserWorkspace } from '@/auth/states/currentUserWorkspaceState'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { FeatureFlagKey, OnboardingStatus, + SettingsFeatures, SubscriptionInterval, SubscriptionStatus, User, @@ -29,6 +31,7 @@ type MockedUser = Pick< currentWorkspace: Workspace; workspaces: Array<{ workspace: Workspace }>; workspaceMembers: WorkspaceMember[]; + currentUserWorkspace: CurrentUserWorkspace; }; export const avatarUrl = @@ -125,6 +128,9 @@ export const mockedUserData: MockedUser = { 'a95afad9ff6f0b364e2a3fd3e246a1a852c22b6e55a3ca33745a86c201f9c10d', workspaceMember: mockedWorkspaceMemberData, currentWorkspace: mockCurrentWorkspace, + currentUserWorkspace: { + settingsPermissions: [SettingsFeatures.WORKSPACE_USERS], + }, locale: 'en', workspaces: [{ workspace: mockCurrentWorkspace }], workspaceMembers: [mockedWorkspaceMemberData],