Add object level permission permissions to role page (ReadOnly) (#11568)

## Context
This PR adds the display of object-level permissions. A following PR
will add the ability to update those permissions.
The PR contains the SettingsRoleObjectLevel page but it's not fully
implemented yet (save won't trigger the corresponding mutation)

<img width="616" alt="Screenshot 2025-04-14 at 18 02 40"
src="https://github.com/user-attachments/assets/f8c58193-31f3-468a-a96d-f06a9f2e1423"
/>
This commit is contained in:
Weiko
2025-04-15 18:46:36 +02:00
committed by GitHub
parent c23942ce6f
commit 43af5ceb5e
41 changed files with 1092 additions and 268 deletions
@@ -0,0 +1,58 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { t } from '@lingui/core/macro';
import { ChangeEvent, useState } from 'react';
import { useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
type SettingsRolePermissionsObjectLevelObjectPickerDropdownContentProps = {
excludedObjectMetadataIds: string[];
onSelect: (objectMetadataId: string) => void;
};
export const SettingsRolePermissionsObjectLevelObjectPickerDropdownContent = ({
excludedObjectMetadataIds,
onSelect,
}: SettingsRolePermissionsObjectLevelObjectPickerDropdownContentProps) => {
const [searchFilter, setSearchFilter] = useState('');
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const { getIcon } = useIcons();
const handleSearchFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
setSearchFilter(event.target.value);
};
const filteredObjectMetadataItems = objectMetadataItems.filter(
(objectMetadataItem) =>
objectMetadataItem.labelSingular
.toLowerCase()
.includes(searchFilter.toLowerCase()) &&
!excludedObjectMetadataIds.includes(objectMetadataItem.id),
);
return (
<DropdownMenu>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleSearchFilterChange}
placeholder={t`Search`}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredObjectMetadataItems.map((objectMetadataItem) => (
<MenuItem
key={objectMetadataItem.id}
text={objectMetadataItem.labelSingular}
LeftIcon={getIcon(objectMetadataItem.icon)}
onClick={() => onSelect(objectMetadataItem.id)}
/>
))}
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};
@@ -0,0 +1,96 @@
import { SETTINGS_ROLE_OBJECT_PERMISSION_ICON_CONFIG } from '@/settings/roles/role-permissions/objects-permissions/constants/settingsRoleObjectPermissionIconConfig';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ObjectPermission } from '~/generated/graphql';
const StyledIconWrapper = styled.div<{ isForbidden?: boolean }>`
align-items: center;
background: ${({ theme, isForbidden }) =>
isForbidden ? theme.adaptiveColors.orange1 : theme.adaptiveColors.blue1};
border: 1px solid
${({ theme, isForbidden }) =>
isForbidden ? theme.adaptiveColors.orange3 : theme.adaptiveColors.blue3};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
height: ${({ theme }) => theme.spacing(4)};
justify-content: center;
width: ${({ theme }) => theme.spacing(4)};
`;
const StyledIcon = styled.div<{ isForbidden?: boolean }>`
align-items: center;
display: flex;
color: ${({ theme, isForbidden }) =>
isForbidden ? theme.color.orange : theme.color.blue};
justify-content: center;
`;
const StyledSettingsRolePermissionsObjectLevelOverrideCell = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
type SettingsRolePermissionsObjectLevelOverrideCellProps = {
objectPermission: ObjectPermission;
};
export const SettingsRolePermissionsObjectLevelOverrideCell = ({
objectPermission,
}: SettingsRolePermissionsObjectLevelOverrideCellProps) => {
const theme = useTheme();
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(objectPermission.roleId),
);
const permissionMappings = {
canReadObjectRecords: 'canReadAllObjectRecords',
canUpdateObjectRecords: 'canUpdateAllObjectRecords',
canSoftDeleteObjectRecords: 'canSoftDeleteAllObjectRecords',
canDestroyObjectRecords: 'canDestroyAllObjectRecords',
} as const;
type ObjectPermissionKey = keyof typeof permissionMappings;
const isOverridden = (permission: ObjectPermissionKey) => {
const rolePermission = permissionMappings[permission];
return (
isDefined(objectPermission[permission]) &&
!!settingsDraftRole[rolePermission as keyof typeof settingsDraftRole] !==
!!objectPermission[permission]
);
};
return (
<StyledSettingsRolePermissionsObjectLevelOverrideCell>
{(Object.keys(permissionMappings) as ObjectPermissionKey[]).map(
(permission) => {
const { Icon, IconForbidden: IconOverride } =
SETTINGS_ROLE_OBJECT_PERMISSION_ICON_CONFIG[permission];
const permissionValue = objectPermission[permission];
if (!isOverridden(permission)) {
return null;
}
return (
<StyledIconWrapper
key={permission}
isForbidden={permissionValue === false}
>
<StyledIcon isForbidden={permissionValue === false}>
{permissionValue === false && (
<IconOverride size={theme.icon.size.sm} />
)}
{permissionValue === true && <Icon size={theme.icon.size.sm} />}
</StyledIcon>
</StyledIconWrapper>
);
},
)}
</StyledSettingsRolePermissionsObjectLevelOverrideCell>
);
};
@@ -0,0 +1,113 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsRolePermissionsObjectLevelTableHeader } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableHeader';
import { SettingsRolePermissionsObjectLevelTableRow } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
// const StyledCreateObjectOverrideSection = styled(Section)`
// border-top: 1px solid ${({ theme }) => theme.border.color.light};
// display: flex;
// justify-content: flex-end;
// padding-top: ${({ theme }) => theme.spacing(2)};
// padding-bottom: ${({ theme }) => theme.spacing(2)};
// `;
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
type SettingsRolePermissionsObjectLevelSectionProps = {
roleId: string;
isEditable: boolean;
};
const StyledNoOverride = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const SettingsRolePermissionsObjectLevelSection = ({
roleId,
}: SettingsRolePermissionsObjectLevelSectionProps) => {
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const objectMetadataItems = useObjectMetadataItems();
const objectMetadataMap = objectMetadataItems.objectMetadataItems.reduce(
(acc, item) => {
acc[item.id] = item;
return acc;
},
{} as Record<string, ObjectMetadataItem>,
);
const objectPermissions = settingsDraftRole.objectPermissions;
// const handleSelectObjectMetadata = (objectMetadataId: string) => {
// setSettingsDraftRole((draftRole) => ({
// ...draftRole,
// objectPermissions: [
// ...(draftRole.objectPermissions ?? []),
// { objectMetadataId, roleId, id: v4() },
// ],
// }));
// };
return (
<Section>
<H2Title
title={t`Object-Level Permissions`}
description={t`Set additional object-level permissions`}
/>
<Table>
<SettingsRolePermissionsObjectLevelTableHeader />
<StyledTableRows>
{isDefined(objectPermissions) && objectPermissions?.length > 0 ? (
objectPermissions?.map((objectPermission) => (
<SettingsRolePermissionsObjectLevelTableRow
key={objectPermission.id}
objectPermission={objectPermission}
objectMetadataItem={
objectMetadataMap[objectPermission.objectMetadataId]
}
/>
))
) : (
<StyledNoOverride>{t`No overrides found`}</StyledNoOverride>
)}
</StyledTableRows>
</Table>
{/* <StyledCreateObjectOverrideSection>
<Dropdown
dropdownId="role-object-select"
dropdownHotkeyScope={{ scope: 'roleObject' }}
clickableComponent={
<Button
Icon={IconPlus}
title={t`Add Object`}
variant="secondary"
size="small"
disabled={!isEditable}
/>
}
dropdownComponents={
<SettingsRolePermissionsObjectLevelObjectPickerDropdownContent
excludedObjectMetadataIds={[]}
onSelect={handleSelectObjectMetadata}
/>
}
/>
</StyledCreateObjectOverrideSection> */}
</Section>
);
};
@@ -0,0 +1,11 @@
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { t } from '@lingui/core/macro';
export const SettingsRolePermissionsObjectLevelTableHeader = () => (
<TableRow>
<TableHeader>{t`Object`}</TableHeader>
<TableHeader>{t`Permission overrides`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
);
@@ -0,0 +1,73 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsRolePermissionsObjectLevelOverrideCell } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelOverrideCell';
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronRight, useIcons } from 'twenty-ui/display';
import { ObjectPermission } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledNameLabel = styled.div`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
type SettingsRolePermissionsObjectLevelTableRowProps = {
objectPermission: ObjectPermission;
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsRolePermissionsObjectLevelTableRow = ({
objectPermission,
objectMetadataItem,
}: SettingsRolePermissionsObjectLevelTableRowProps) => {
const { getIcon } = useIcons();
const theme = useTheme();
if (!objectMetadataItem) {
throw new Error('Object metadata item not found');
}
const Icon = getIcon(objectMetadataItem.icon);
return (
<TableRow
to={getSettingsPath(SettingsPath.RoleObjectLevel, {
roleId: objectPermission.roleId,
objectMetadataId: objectPermission.objectMetadataId,
})}
>
<StyledNameTableCell>
{!!Icon && (
<Icon
style={{ minWidth: theme.icon.size.md }}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
<StyledNameLabel title={objectMetadataItem.labelPlural}>
{objectMetadataItem.labelPlural}
</StyledNameLabel>
</StyledNameTableCell>
<TableCell>
<SettingsRolePermissionsObjectLevelOverrideCell
objectPermission={objectPermission}
/>
</TableCell>
<TableCell align={'right'}>
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,94 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/components/SettingsDataModelObjectTypeTag';
import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel';
import { SettingsRolePermissionsObjectLevelObjectFormObjectLevel } from '@/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectFormObjectLevel';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { H3Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledObjectTypeTag = styled(SettingsDataModelObjectTypeTag)`
box-sizing: border-box;
height: ${({ theme }) => theme.spacing(5)};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitleContainer = styled.div`
display: flex;
`;
type SettingsRolePermissionsObjectLevelObjectFormProps = {
roleId: string;
objectMetadataId: string;
};
export const SettingsRolePermissionsObjectLevelObjectForm = ({
roleId,
objectMetadataId,
}: SettingsRolePermissionsObjectLevelObjectFormProps) => {
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const objectMetadata = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const objectMetadataItem = objectMetadata.objectMetadataItem;
const objectTypeLabel = getObjectTypeLabel(objectMetadataItem);
return (
<SubMenuTopBarContainer
title={
<StyledTitleContainer>
<H3Title title={objectMetadataItem.labelPlural} />
<StyledObjectTypeTag objectTypeLabel={objectTypeLabel} />
</StyledTitleContainer>
}
links={[
{
children: 'Workspace',
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: 'Roles',
href: getSettingsPath(SettingsPath.Roles),
},
{
children: settingsDraftRole.label,
href: getSettingsPath(SettingsPath.RoleDetail, {
roleId,
}),
},
{
children: `Permissions · ${objectMetadataItem.labelSingular}`,
},
]}
actionButton={
<Button
title={t`Back`}
variant="primary"
size="small"
accent="blue"
to={getSettingsPath(SettingsPath.RoleDetail, {
roleId,
})}
/>
}
>
<SettingsPageContainer>
<SettingsRolePermissionsObjectLevelObjectFormObjectLevel
objectMetadataItem={objectMetadataItem}
roleId={roleId}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,100 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsRolePermissionsObjectLevelObjectFormObjectLevelHeader } from '@/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectFormObjectLevelHeader';
import { SettingsRolePermissionsObjectsTableRow } from '@/settings/roles/role-permissions/objects-permissions/components/SettingsRolePermissionsObjectsTableRow';
import { SettingsRolePermissionsObjectPermission } from '@/settings/roles/role-permissions/objects-permissions/types/SettingsRolePermissionsObjectPermission';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
type SettingsRolePermissionsObjectLevelObjectFormObjectLevelProps = {
roleId: string;
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsRolePermissionsObjectLevelObjectFormObjectLevel = ({
roleId,
objectMetadataItem,
}: SettingsRolePermissionsObjectLevelObjectFormObjectLevelProps) => {
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const settingsDraftRoleObjectPermissions =
settingsDraftRole.objectPermissions?.find(
(permission) => permission.objectMetadataId === objectMetadataItem.id,
);
if (!settingsDraftRoleObjectPermissions) {
return null;
}
const objectLabel = objectMetadataItem.labelPlural;
const objectPermissionsConfig: SettingsRolePermissionsObjectPermission[] = [
{
key: 'canReadObjectRecords',
label: t`See Records on ${objectLabel}`,
value: settingsDraftRoleObjectPermissions.canReadObjectRecords,
setValue: (_value: boolean) => {
// TODO: Implement
},
},
{
key: 'canUpdateObjectRecords',
label: t`Edit Records on ${objectLabel}`,
value: settingsDraftRoleObjectPermissions.canUpdateObjectRecords,
setValue: (_value: boolean) => {
// TODO: Implement
},
},
{
key: 'canSoftDeleteObjectRecords',
label: t`Delete Records on ${objectLabel}`,
value: settingsDraftRoleObjectPermissions.canSoftDeleteObjectRecords,
setValue: (_value: boolean) => {
// TODO: Implement
},
},
{
key: 'canDestroyObjectRecords',
label: t`Destroy Records on ${objectLabel}`,
value: settingsDraftRoleObjectPermissions.canDestroyObjectRecords,
setValue: (_value: boolean) => {
// TODO: Implement
},
},
];
return (
<Section>
<H2Title
title={t`Object-Level Permissions`}
description={t`Ability to interact with this specific object`}
/>
<StyledTable>
<SettingsRolePermissionsObjectLevelObjectFormObjectLevelHeader />
<StyledTableRows>
{objectPermissionsConfig.map((permission) => (
<SettingsRolePermissionsObjectsTableRow
key={permission.key}
permission={permission}
isEditable={settingsDraftRole.isEditable}
/>
))}
</StyledTableRows>
</StyledTable>
</Section>
);
};
@@ -0,0 +1,11 @@
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { t } from '@lingui/core/macro';
export const SettingsRolePermissionsObjectLevelObjectFormObjectLevelHeader =
() => (
<TableRow gridAutoColumns="1fr 24px">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader aria-label={t`Actions`}></TableHeader>
</TableRow>
);