Files
twenty/packages/twenty-front/src/modules/users/components/UserAndViewsProviderEffect.tsx
T
Félix Malfait cff17db6cb Enhance role-check system with stricter checks (#15392)
## Overview

This PR strengthens our permission system by introducing more granular
role-based access control across the platform.

## Changes

### New Permissions Added
- **Applications** - Control who can install and manage applications
- **Layouts** - Control who can customize page layouts and UI structure
- **AI** - Control access to AI features and agents
- **Upload File** - Separate permission for file uploads
- **Download File** - Separate permission for file downloads (frontend
visibility)

### Security Enhancements
- Implemented whitelist-based validation for workspace field updates
- Added explicit permission guards to core entity resolvers
- Enhanced ESLint rule to enforce permission checks on all mutations
- Created `CustomPermissionGuard` and `NoPermissionGuard` for better
code documentation

### Affected Components
- Core entity resolvers: webhooks, files, domains, applications,
layouts, postgres credentials
- Workspace update mutations now use whitelist validation
- Settings UI updated with new permission controls

### Developer Experience
- ESLint now catches missing permission guards during development
- Explicit guard markers make permission requirements clear in code
review
- Comprehensive test coverage for new permission logic

## Testing
-  All TypeScript type checks pass
-  ESLint validation passes
-  New permission guards properly enforced
-  Frontend UI displays new permissions correctly

## Migration Notes
Existing workspaces will need to assign the new permissions to roles as
needed. By default, all new permissions are set to `false` for non-admin
roles.
2025-11-07 15:37:17 +01:00

217 lines
7.4 KiB
TypeScript

import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorkspaceDeletedMembersState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences';
import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale.util';
import { coreViewsState } from '@/views/states/coreViewState';
import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations';
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { enUS } from 'date-fns/locale';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { AppPath, type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type WorkspaceMember,
useFindAllCoreViewsQuery,
useGetCurrentUserQuery,
} from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const UserAndViewsProviderEffect = () => {
const location = useLocation();
const [isCurrentUserLoaded, setIsCurrentUserLoaded] = useRecoilState(
isCurrentUserLoadedState,
);
const [localIsCurrentUserLoaded, setLocalIsCurrentUserLoaded] =
useState(false);
const [localAreViewsLoaded, setLocalAreViewsLoaded] = useState(false);
const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
const { initializeFormatPreferences } = useInitializeFormatPreferences();
const isLoggedIn = useIsLogged();
const updateLocaleCatalog = useRecoilCallback(
({ snapshot, set }) =>
async (newLocale: keyof typeof APP_LOCALES) => {
const localeValue = snapshot.getLoadable(dateLocaleState).getValue();
if (localeValue.locale !== newLocale) {
getDateFnsLocale(newLocale).then((localeCatalog) => {
set(dateLocaleState, {
locale: newLocale,
localeCatalog: localeCatalog || enUS,
});
});
}
},
[],
);
const setCoreViews = useRecoilCallback(
({ set, snapshot }) =>
(coreViews: CoreViewWithRelations[]) => {
const existingCoreViews = snapshot
.getLoadable(coreViewsState)
.getValue();
if (!isDeeplyEqual(existingCoreViews, coreViews)) {
set(coreViewsState, coreViews);
}
},
[],
);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
const setCurrentWorkspaceMembers = useSetRecoilState(
currentWorkspaceMembersState,
);
const setCurrentWorkspaceMembersWithDeleted = useSetRecoilState(
currentWorkspaceDeletedMembersState,
);
const shouldSkip =
!isLoggedIn ||
isCurrentUserLoaded ||
isMatchingLocation(location, AppPath.Verify) ||
isMatchingLocation(location, AppPath.VerifyEmail);
const { data: userQueryData, loading: userQueryLoading } =
useGetCurrentUserQuery({
skip: shouldSkip,
});
const { data: queryDataCoreViews, loading: queryLoadingCoreViews } =
useFindAllCoreViewsQuery({
skip: shouldSkip,
});
useEffect(() => {
if (!userQueryLoading) {
setLocalIsCurrentUserLoaded(true);
}
if (!isDefined(userQueryData?.currentUser)) return;
setCurrentUser(userQueryData.currentUser);
if (isDefined(userQueryData.currentUser.currentWorkspace)) {
setCurrentWorkspace({
...userQueryData.currentUser.currentWorkspace,
defaultRole:
userQueryData.currentUser.currentWorkspace.defaultRole ?? null,
});
}
if (isDefined(userQueryData.currentUser.currentUserWorkspace)) {
setCurrentUserWorkspace({
permissionFlags:
userQueryData.currentUser.currentUserWorkspace.permissionFlags ?? [],
twoFactorAuthenticationMethodSummary:
userQueryData.currentUser.currentUserWorkspace
.twoFactorAuthenticationMethodSummary ?? [],
objectsPermissions:
(userQueryData.currentUser.currentUserWorkspace
.objectsPermissions as Array<
ObjectPermissions & { objectMetadataId: string }
>) ?? [],
});
}
const {
workspaceMember,
workspaceMembers,
deletedWorkspaceMembers,
availableWorkspaces,
} = userQueryData.currentUser;
const affectDefaultValuesOnEmptyWorkspaceMemberFields = (
workspaceMember: WorkspaceMember,
) => {
return {
...workspaceMember,
colorScheme: (workspaceMember.colorScheme as ColorScheme) ?? 'Light',
locale:
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE,
};
};
if (isDefined(workspaceMember)) {
const updatedWorkspaceMember =
affectDefaultValuesOnEmptyWorkspaceMemberFields(workspaceMember);
setCurrentWorkspaceMember(updatedWorkspaceMember);
updateLocaleCatalog(updatedWorkspaceMember.locale);
// Initialize format preferences from workspace member
initializeFormatPreferences(updatedWorkspaceMember);
dynamicActivate(
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE,
);
}
if (isDefined(workspaceMembers)) {
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(deletedWorkspaceMembers)) {
setCurrentWorkspaceMembersWithDeleted(deletedWorkspaceMembers);
}
if (isDefined(availableWorkspaces)) {
setAvailableWorkspaces(availableWorkspaces);
}
}, [
userQueryLoading,
userQueryData?.currentUser,
setCurrentUser,
setCurrentUserWorkspace,
setCurrentWorkspaceMembers,
setAvailableWorkspaces,
setCurrentWorkspace,
setCurrentWorkspaceMember,
setIsCurrentUserLoaded,
initializeFormatPreferences,
setCurrentWorkspaceMembersWithDeleted,
updateLocaleCatalog,
setCoreViews,
]);
useEffect(() => {
if (!queryLoadingCoreViews) {
setLocalAreViewsLoaded(true);
}
if (!isDefined(queryDataCoreViews?.getCoreViews)) return;
setCoreViews(queryDataCoreViews.getCoreViews);
}, [queryDataCoreViews?.getCoreViews, setCoreViews, queryLoadingCoreViews]);
useEffect(() => {
if (localIsCurrentUserLoaded && localAreViewsLoaded) {
setIsCurrentUserLoaded(true);
}
}, [localIsCurrentUserLoaded, localAreViewsLoaded, setIsCurrentUserLoaded]);
return null;
};