cff17db6cb
## 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.
217 lines
7.4 KiB
TypeScript
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;
|
|
};
|