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],