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
This commit is contained in:
Weiko
2025-02-18 15:50:23 +01:00
committed by GitHub
parent 755d1786fc
commit 2fca60436b
20 changed files with 660 additions and 318 deletions

View File

@ -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<Options<any>> = {}) => {
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<Options<any>> = {}) => {
setCurrentUser(null);
setCurrentWorkspaceMember(null);
setCurrentWorkspace(null);
setCurrentUserWorkspace(null);
setWorkspaces([]);
if (
!isMatchingLocation(AppPath.Verify) &&

View File

@ -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 (
<RouterProvider
router={useCreateAppRouter(
isBillingPageEnabled,
isFunctionSettingsEnabled,
isAdminPageEnabled,
isPermissionsEnabled,
)}
router={useCreateAppRouter(isFunctionSettingsEnabled, isAdminPageEnabled)}
/>
);
};

View File

@ -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) => (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Routes>
@ -290,35 +289,50 @@ export const SettingsRoutes = ({
path={SettingsPath.AccountsEmails}
element={<SettingsAccountsEmails />}
/>
{isBillingEnabled && (
<Route
element={
<SettingsProtectedRouteWrapper
requiredFeatureFlag={FeatureFlagKey.IsBillingPlansEnabled}
/>
}
>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
)}
</Route>
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />}
/>
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route path={SettingsPath.Objects} element={<SettingsObjects />} />
<Route
path={SettingsPath.ObjectOverview}
element={<SettingsObjectOverview />}
/>
<Route
path={SettingsPath.ObjectDetail}
element={<SettingsObjectDetailPage />}
/>
<Route path={SettingsPath.NewObject} element={<SettingsNewObject />} />
{isPermissionsEnabled && (
<>
<Route path={SettingsPath.Roles} element={<SettingsRoles />} />
<Route
path={SettingsPath.RoleDetail}
element={<SettingsRoleEdit />}
element={
<SettingsProtectedRouteWrapper
settingsPermission={SettingsFeatures.DATA_MODEL}
/>
</>
)}
}
>
<Route path={SettingsPath.Objects} element={<SettingsObjects />} />
<Route
path={SettingsPath.ObjectOverview}
element={<SettingsObjectOverview />}
/>
<Route
path={SettingsPath.ObjectDetail}
element={<SettingsObjectDetailPage />}
/>
<Route path={SettingsPath.NewObject} element={<SettingsNewObject />} />
</Route>
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={SettingsFeatures.ROLES}
requiredFeatureFlag={FeatureFlagKey.IsPermissionsEnabled}
/>
}
>
<Route path={SettingsPath.Roles} element={<SettingsRoles />} />
<Route path={SettingsPath.RoleDetail} element={<SettingsRoleEdit />} />
</Route>
<Route path={SettingsPath.Developers} element={<SettingsDevelopers />} />
<Route
path={SettingsPath.DevelopersNewApiKey}

View File

@ -26,10 +26,8 @@ import { PaymentSuccess } from '~/pages/onboarding/PaymentSuccess';
import { SyncEmails } from '~/pages/onboarding/SyncEmails';
export const useCreateAppRouter = (
isBillingEnabled?: boolean,
isFunctionSettingsEnabled?: boolean,
isAdminPageEnabled?: boolean,
isPermissionsEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
@ -61,10 +59,8 @@ export const useCreateAppRouter = (
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isBillingEnabled={isBillingEnabled}
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
isPermissionsEnabled={isPermissionsEnabled}
/>
}
/>

View File

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

View File

@ -0,0 +1,10 @@
import { createState } from '@ui/utilities/state/utils/createState';
import { UserWorkspace } from '~/generated/graphql';
export type CurrentUserWorkspace = Pick<UserWorkspace, 'settingsPermissions'>;
export const currentUserWorkspaceState =
createState<CurrentUserWorkspace | null>({
key: 'currentUserWorkspaceState',
defaultValue: null,
});

View File

@ -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 (
<AdvancedSettingsWrapper navigationDrawerItem>
<NavigationDrawerItem
indentationLevel={item.indentationLevel}
subItemState={subItemState}
label={item.label}
to={href}
Icon={item.Icon}
active={isActive}
soon={item.soon}
/>
</AdvancedSettingsWrapper>
);
}
return (
<NavigationDrawerItem
indentationLevel={indentationLevel}
indentationLevel={item.indentationLevel}
subItemState={subItemState}
label={label}
label={item.label}
to={href}
Icon={Icon}
Icon={item.Icon}
active={isActive}
soon={soon}
soon={item.soon}
/>
);
};

View File

@ -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 (
<>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label={t`User`} />
<SettingsNavigationDrawerItem
label={t`Profile`}
path={SettingsPath.ProfilePage}
Icon={IconUserCircle}
/>
<SettingsNavigationDrawerItem
label={t`Experience`}
path={SettingsPath.Experience}
Icon={IconColorSwatch}
/>
<NavigationDrawerItemGroup>
<SettingsNavigationDrawerItem
label={t`Accounts`}
path={SettingsPath.Accounts}
Icon={IconAt}
matchSubPages={false}
/>
{accountSubSettings.map((navigationItem, index) => (
<SettingsNavigationDrawerItem
key={index}
label={navigationItem.label}
path={navigationItem.path}
Icon={navigationItem.Icon}
indentationLevel={navigationItem.indentationLevel}
subItemState={getNavigationSubItemLeftAdornment({
arrayLength: accountSubSettings.length,
index,
selectedIndex,
})}
/>
))}
</NavigationDrawerItemGroup>
</NavigationDrawerSection>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label={t`Workspace`} />
<SettingsNavigationDrawerItem
label={t`General`}
path={SettingsPath.Workspace}
Icon={IconSettings}
/>
<SettingsNavigationDrawerItem
label={t`Members`}
path={SettingsPath.WorkspaceMembersPage}
Icon={IconUsers}
/>
{isBillingPageEnabled && (
<SettingsNavigationDrawerItem
label={t`Billing`}
path={SettingsPath.Billing}
Icon={IconCurrencyDollar}
/>
)}
{isPermissionsEnabled && (
<SettingsNavigationDrawerItem
label={t`Roles`}
path={SettingsPath.Roles}
Icon={IconLock}
/>
)}
<SettingsNavigationDrawerItem
label={t`Data model`}
path={SettingsPath.Objects}
Icon={IconHierarchy2}
/>
<SettingsNavigationDrawerItem
label={t`Integrations`}
path={SettingsPath.Integrations}
Icon={IconApps}
/>
<AdvancedSettingsWrapper navigationDrawerItem={true}>
<SettingsNavigationDrawerItem
label={t`Security`}
path={SettingsPath.Security}
Icon={IconKey}
/>
</AdvancedSettingsWrapper>
</NavigationDrawerSection>
{settingsNavigationItems.map((section) => {
const allItemsHidden = section.items.every((item) => item.isHidden);
if (allItemsHidden) {
return null;
}
return (
<NavigationDrawerSection key={section.label}>
{section.isAdvanced ? (
<AdvancedSettingsWrapper hideIcon>
<NavigationDrawerSectionTitle label={section.label} />
</AdvancedSettingsWrapper>
) : (
<NavigationDrawerSectionTitle label={section.label} />
)}
{section.items.map((item, index) => {
const subItems = item.subItems;
if (Array.isArray(subItems) && subItems.length > 0) {
const selectedSubItemIndex =
getSelectedIndexForSubItems(subItems);
return (
<NavigationDrawerItemGroup key={item.path}>
<SettingsNavigationDrawerItem
item={item}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
{subItems.map((subItem, subIndex) => (
<SettingsNavigationDrawerItem
key={subItem.path}
item={subItem}
subItemState={
subItem.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: subItems.length,
index: subIndex,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
))}
</NavigationDrawerItemGroup>
);
}
return (
<SettingsNavigationDrawerItem
key={item.path}
item={item}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: index,
})
: undefined
}
/>
);
})}
</NavigationDrawerSection>
);
})}
<NavigationDrawerSection>
<AdvancedSettingsWrapper hideIcon>
<NavigationDrawerSectionTitle label={t`Developers`} />
</AdvancedSettingsWrapper>
<AdvancedSettingsWrapper navigationDrawerItem={true}>
<SettingsNavigationDrawerItem
label={t`API & Webhooks`}
path={SettingsPath.Developers}
Icon={IconCode}
/>
</AdvancedSettingsWrapper>
{isFunctionSettingsEnabled && (
<AdvancedSettingsWrapper navigationDrawerItem={true}>
<SettingsNavigationDrawerItem
label={t`Functions`}
path={SettingsPath.ServerlessFunctions}
Icon={IconFunction}
/>
</AdvancedSettingsWrapper>
)}
</NavigationDrawerSection>
<NavigationDrawerSection>
<NavigationDrawerSectionTitle label={t`Other`} />
{isAdminPageEnabled && (
<SettingsNavigationDrawerItem
label={t`Server Admin`}
path={SettingsPath.AdminPanel}
Icon={IconServer}
/>
)}
{labPublicFeatureFlags?.length > 0 && (
<SettingsNavigationDrawerItem
label={t`Lab`}
path={SettingsPath.Lab}
Icon={IconFlask}
/>
)}
<SettingsNavigationDrawerItem
label={t`Releases`}
path={SettingsPath.Releases}
Icon={IconRocket}
/>
<NavigationDrawerItem
label={t`Logout`}
onClick={signOut}

View File

@ -0,0 +1,36 @@
import { useHasSettingsPermission } from '@/settings/roles/hooks/useHasSettingsPermission';
import { SettingsPath } from '@/types/SettingsPath';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { ReactNode } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { FeatureFlagKey, SettingsFeatures } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsProtectedRouteWrapperProps = {
children?: ReactNode;
settingsPermission?: SettingsFeatures;
requiredFeatureFlag?: FeatureFlagKey;
};
export const SettingsProtectedRouteWrapper = ({
children,
settingsPermission,
requiredFeatureFlag,
}: SettingsProtectedRouteWrapperProps) => {
const isPermissionsEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
);
const hasPermission = useHasSettingsPermission(settingsPermission);
const requiredFeatureFlagEnabled = useIsFeatureEnabled(
requiredFeatureFlag || null,
);
if (
(requiredFeatureFlag && !requiredFeatureFlagEnabled) ||
(!hasPermission && isPermissionsEnabled)
) {
return <Navigate to={getSettingsPath(SettingsPath.ProfilePage)} replace />;
}
return children ?? <Outlet />;
};

View File

@ -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 }) => (
<MockedProvider>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter>{children}</MemoryRouter>
</RecoilRoot>
</MockedProvider>
);
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);
});
});

View File

@ -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,
},
],
},
];
};

View File

@ -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);
};

View File

@ -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);
};

View File

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

View File

@ -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<FeatureFlagKey, boolean> => {
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);
};

View File

@ -103,39 +103,37 @@ export const SettingsRoleEdit = () => {
};
return (
<>
<SubMenuTopBarContainer
title={
<StyledTitleContainer>
<StyledIconUser size={16} />
<H3Title title={role.label} />
</StyledTitleContainer>
}
links={[
{
children: 'Workspace',
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: 'Roles',
href: getSettingsPath(SettingsPath.Roles),
},
{
children: role.label,
},
]}
>
<SettingsPageContainer>
<TabList
tabListInstanceId={SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID}
tabs={tabs}
className="tab-list"
/>
<StyledContentContainer>
{renderActiveTabContent()}
</StyledContentContainer>
</SettingsPageContainer>
</SubMenuTopBarContainer>
</>
<SubMenuTopBarContainer
title={
<StyledTitleContainer>
<StyledIconUser size={16} />
<H3Title title={role.label} />
</StyledTitleContainer>
}
links={[
{
children: 'Workspace',
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: 'Roles',
href: getSettingsPath(SettingsPath.Roles),
},
{
children: role.label,
},
]}
>
<SettingsPageContainer>
<TabList
tabListInstanceId={SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID}
tabs={tabs}
className="tab-list"
/>
<StyledContentContainer>
{renderActiveTabContent()}
</StyledContentContainer>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -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) => (
<>
<React.Fragment key={workspaceMember.id}>
<StyledAvatarContainer
key={workspaceMember.id}
id={`avatar-${workspaceMember.id}`}
>
<Avatar
@ -180,7 +173,7 @@ export const SettingsRoles = () => {
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</>
</React.Fragment>
))}
</StyledAvatarGroup>
<StyledAssignedText>

View File

@ -19,8 +19,8 @@ export const RoleWorkspaceMemberPickerDropdownContent = ({
return null;
}
if (!filteredWorkspaceMembers?.length && searchFilter?.length > 0) {
return <MenuItem disabled text={t`No Result`} />;
if (!filteredWorkspaceMembers.length && searchFilter.length > 0) {
return <MenuItem disabled text={t`No Results`} />;
}
return (

View File

@ -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 (
<>

View File

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