add object settings permission tab (#10159)

## Context
Introducing the "Permissions" tab in the role page

Next: Need to address some css improvements, some components might be
reusable and it still does not fully match the figma (icon missing for
permission types for example). We decided to merge like this for now so
we have something functional and I will update the code in an upcoming
PR

<img width="633" alt="Screenshot 2025-02-12 at 13 54 16"
src="https://github.com/user-attachments/assets/762db5d7-e0a6-4ee1-b299-24de6645bad1"
/>
This commit is contained in:
Weiko
2025-02-12 18:49:50 +01:00
committed by GitHub
parent 61881d6d0f
commit 193ef432a0
18 changed files with 479 additions and 160 deletions

View File

@ -64,7 +64,7 @@ const StyledAvatarGroup = styled.div`
`;
const StyledTableHeaderRow = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledBottomSection = styled(Section)`

View File

@ -1,3 +1,4 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { SettingsPath } from '@/types/SettingsPath';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@ -6,7 +7,16 @@ import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { Button, H2Title, IconPlus, IconSearch, Section } from 'twenty-ui';
import { useRecoilValue } from 'recoil';
import {
AppTooltip,
Button,
H2Title,
IconPlus,
IconSearch,
Section,
TooltipDelay,
} from 'twenty-ui';
import { Role, WorkspaceMember } from '~/generated-metadata/graphql';
import {
GetRolesDocument,
@ -36,14 +46,6 @@ const StyledBottomSection = styled(Section)<{ hasRows: boolean }>`
justify-content: flex-end;
`;
const StyledEmptyText = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
justify-content: center;
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledSearchContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
@ -80,6 +82,7 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
const { data: rolesData } = useGetRolesQuery();
const { closeDropdown } = useDropdown('role-member-select');
const [searchFilter, setSearchFilter] = useState('');
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const workspaceMemberRoleMap = new Map<
string,
@ -154,6 +157,9 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
setSearchFilter(text);
};
const allWorkspaceMembersHaveThisRole =
role.workspaceMembers.length === currentWorkspaceMembers.length;
return (
<>
<Section>
@ -180,13 +186,6 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
onRemove={() => handleRemoveClick(workspaceMember)}
/>
))}
{filteredWorkspaceMembers.length === 0 && (
<StyledEmptyText>
{searchFilter
? t`No members matching your search`
: t`No members assigned to this role yet`}
</StyledEmptyText>
)}
</Table>
</Section>
<StyledBottomSection hasRows={filteredWorkspaceMembers.length > 0}>
@ -194,12 +193,23 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
dropdownId="role-member-select"
dropdownHotkeyScope={{ scope: 'roleAssignment' }}
clickableComponent={
<Button
Icon={IconPlus}
title={t`Assign to member`}
variant="secondary"
size="small"
/>
<>
<div id="assign-member">
<Button
Icon={IconPlus}
title={t`Assign to member`}
variant="secondary"
size="small"
disabled={allWorkspaceMembersHaveThisRole}
/>
</div>
<AppTooltip
anchorSelect="#assign-member"
content={t`No more members to assign`}
delay={TooltipDelay.noDelay}
hidden={!allWorkspaceMembersHaveThisRole}
/>
</>
}
dropdownComponents={
<RoleWorkspaceMemberPickerDropdown

View File

@ -5,7 +5,7 @@ import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
const StyledTableHeaderRow = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
type RoleAssignmentTableHeaderProps = {

View File

@ -1,19 +1,126 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, Section } from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
import {
H2Title,
IconEye,
IconPencil,
IconTrash,
IconTrashX,
Section,
} from 'twenty-ui';
import { Role, SettingsFeatures } from '~/generated-metadata/graphql';
import { RolePermissionsObjectsTableHeader } from '~/pages/settings/roles/components/RolePermissionsObjectsTableHeader';
import { RolePermissionsSettingsTableHeader } from '~/pages/settings/roles/components/RolePermissionsSettingsTableHeader';
import { RolePermissionsSettingsTableRow } from '~/pages/settings/roles/components/RolePermissionsSettingsTableRow';
import { RolePermissionsObjectPermission } from '~/pages/settings/roles/types/RolePermissionsObjectPermission';
import { RolePermissionsObjectsTableRow } from './RolePermissionsObjectsTableRow';
type RolePermissionsProps = {
role: Pick<Role, 'id' | 'label' | 'canUpdateAllSettings'>;
};
const StyledRolePermissionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(8)};
`;
export const RolePermissions = ({ role }: { role: Role }) => {
const objectPermissionsConfig: RolePermissionsObjectPermission[] = [
{
key: 'seeRecords',
label: 'See Records on All Objects',
icon: <IconEye size={14} />,
value: true,
},
{
key: 'editRecords',
label: 'Edit Records on All Objects',
icon: <IconPencil size={14} />,
value: true,
},
{
key: 'deleteRecords',
label: 'Delete Records on All Objects',
icon: <IconTrash size={14} />,
value: true,
},
{
key: 'destroyRecords',
label: 'Destroy Records on All Objects',
icon: <IconTrashX size={14} />,
value: true,
},
];
const settingsPermissionsConfig = [
{
key: SettingsFeatures.API_KEYS_AND_WEBHOOKS,
label: 'API Keys and Webhooks',
type: 'Developer',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.ROLES,
label: 'Roles',
type: 'Members',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.WORKSPACE_SETTINGS,
label: 'Workspace Settings',
type: 'General',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.WORKSPACE_USERS,
label: 'Workspace Users',
type: 'Members',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.DATA_MODEL,
label: 'Data Model',
type: 'Data Model',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.ADMIN_PANEL,
label: 'Admin Panel',
type: 'Admin Panel',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.SECURITY_SETTINGS,
label: 'Security Settings',
type: 'Security',
value: role.canUpdateAllSettings,
},
];
// eslint-disable-next-line unused-imports/no-unused-vars
export const RolePermissions = ({ role }: RolePermissionsProps) => {
return (
<Section>
<H2Title
title={t`Permissions`}
description={t`This Role has the following permissions.`}
/>
</Section>
<StyledRolePermissionsContainer>
<Section>
<H2Title
title={t`Objects`}
description={t`Ability to interact with each object`}
/>
<RolePermissionsObjectsTableHeader allPermissions={true} />
{objectPermissionsConfig.map((permission) => (
<RolePermissionsObjectsTableRow
key={permission.key}
permission={permission}
/>
))}
</Section>
<Section>
<H2Title title={t`Settings`} description={t`Settings permissions`} />
<RolePermissionsSettingsTableHeader
allPermissions={role.canUpdateAllSettings}
/>
{settingsPermissionsConfig.map((permission) => (
<RolePermissionsSettingsTableRow
key={permission.key}
permission={permission}
/>
))}
</Section>
</StyledRolePermissionsContainer>
);
};

View File

@ -0,0 +1,44 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Checkbox } from 'twenty-ui';
const StyledTableHeaderRow = styled(TableRow)`
display: flex;
`;
const StyledNameHeader = styled(TableHeader)`
flex: 1;
`;
const StyledActionsHeader = styled(TableHeader)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTable = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
type RolePermissionsObjectsTableHeaderProps = {
className?: string;
allPermissions: boolean;
};
export const RolePermissionsObjectsTableHeader = ({
className,
allPermissions,
}: RolePermissionsObjectsTableHeaderProps) => (
<StyledTable className={className}>
<StyledTableHeaderRow>
<StyledNameHeader>{t`Name`}</StyledNameHeader>
<StyledActionsHeader aria-label={t`Actions`}>
<Checkbox checked={allPermissions} disabled />
</StyledActionsHeader>
</StyledTableHeaderRow>
</StyledTable>
);

View File

@ -0,0 +1,69 @@
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Checkbox } from 'twenty-ui';
import { RolePermissionsObjectPermission } from '~/pages/settings/roles/types/RolePermissionsObjectPermission';
const StyledIconWrapper = styled.div`
align-items: center;
background: ${({ theme }) => theme.color.blue10};
border: 1px solid ${({ theme }) => theme.color.blue30};
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`
align-items: center;
display: flex;
color: ${({ theme }) => theme.color.blue};
justify-content: center;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledPermissionCell = styled(TableCell)`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledCheckboxCell = styled(TableCell)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTableRow = styled(TableRow)`
align-items: center;
display: flex;
`;
type RolePermissionsObjectsTableRowProps = {
permission: RolePermissionsObjectPermission;
};
export const RolePermissionsObjectsTableRow = ({
permission,
}: RolePermissionsObjectsTableRowProps) => {
return (
<StyledTableRow key={permission.key}>
<StyledPermissionCell>
<StyledIconWrapper>
<StyledIcon>{permission.icon}</StyledIcon>
</StyledIconWrapper>
<StyledLabel>{permission.label}</StyledLabel>
</StyledPermissionCell>
<StyledCheckboxCell>
<Checkbox checked={permission.value} disabled />
</StyledCheckboxCell>
</StyledTableRow>
);
};

View File

@ -0,0 +1,52 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Checkbox } from 'twenty-ui';
const StyledTableHeaderRow = styled(TableRow)`
align-items: center;
display: flex;
height: ${({ theme }) => theme.spacing(8)};
`;
const StyledNameHeader = styled(TableHeader)`
flex: 1;
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledActionsHeader = styled(TableHeader)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTable = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTypeHeader = styled(TableHeader)`
flex: 1;
`;
type RolePermissionsSettingsTableHeaderProps = {
className?: string;
allPermissions: boolean;
};
export const RolePermissionsSettingsTableHeader = ({
className,
allPermissions,
}: RolePermissionsSettingsTableHeaderProps) => (
<StyledTable className={className}>
<StyledTableHeaderRow>
<StyledNameHeader>{t`Name`}</StyledNameHeader>
<StyledTypeHeader>{t`Type`}</StyledTypeHeader>
<StyledActionsHeader aria-label={t`Actions`}>
<Checkbox checked={allPermissions} disabled />
</StyledActionsHeader>
</StyledTableHeaderRow>
</StyledTable>
);

View File

@ -0,0 +1,55 @@
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Checkbox } from 'twenty-ui';
import { RolePermissionsSettingPermission } from '~/pages/settings/roles/types/RolePermissionsSettingPermission';
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledType = styled(StyledLabel)`
color: ${({ theme }) => theme.font.color.secondary};
`;
const StyledPermissionCell = styled(TableCell)`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledCheckboxCell = styled(TableCell)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTableRow = styled(TableRow)`
align-items: center;
display: flex;
`;
type RolePermissionsSettingsTableRowProps = {
permission: RolePermissionsSettingPermission;
};
export const RolePermissionsSettingsTableRow = ({
permission,
}: RolePermissionsSettingsTableRowProps) => {
return (
<StyledTableRow key={permission.key}>
<StyledPermissionCell>
<StyledLabel>{permission.label}</StyledLabel>
</StyledPermissionCell>
<StyledPermissionCell>
<StyledType>{permission.type}</StyledType>
</StyledPermissionCell>
<StyledCheckboxCell>
<Checkbox checked={permission.value} disabled />
</StyledCheckboxCell>
</StyledTableRow>
);
};

View File

@ -1,17 +1,13 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useSearchRecords } from '@/object-record/hooks/useSearchRecords';
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 styled from '@emotion/styled';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ChangeEvent, useState } from 'react';
import { WorkspaceMember } from '~/generated-metadata/graphql';
import { RoleWorkspaceMemberPickerDropdownContent } from './RoleWorkspaceMemberPickerDropdownContent';
const StyledWorkspaceMemberSelectContainer = styled.div`
max-height: ${({ theme }) => theme.spacing(50)};
overflow-y: auto;
`;
type RoleWorkspaceMemberPickerDropdownProps = {
excludedWorkspaceMemberIds: string[];
onSelect: (workspaceMember: WorkspaceMember) => void;
@ -23,23 +19,9 @@ export const RoleWorkspaceMemberPickerDropdown = ({
}: RoleWorkspaceMemberPickerDropdownProps) => {
const [searchFilter, setSearchFilter] = useState('');
const { records: workspaceMembers, loading } = useFindManyRecords({
const { loading, records: workspaceMembers } = useSearchRecords({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
filter: searchFilter
? {
or: [
{
name: { firstName: { ilike: `%${searchFilter}%` } },
},
{
name: { lastName: { ilike: `%${searchFilter}%` } },
},
{
userEmail: { ilike: `%${searchFilter}%` },
},
],
}
: undefined,
searchInput: searchFilter,
});
const filteredWorkspaceMembers = (workspaceMembers?.filter(
@ -52,20 +34,21 @@ export const RoleWorkspaceMemberPickerDropdown = ({
};
return (
<DropdownMenuItemsContainer>
<DropdownMenu>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleSearchFilterChange}
placeholder="Search"
/>
<StyledWorkspaceMemberSelectContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<RoleWorkspaceMemberPickerDropdownContent
loading={loading}
searchFilter={searchFilter}
filteredWorkspaceMembers={filteredWorkspaceMembers}
onSelect={onSelect}
/>
</StyledWorkspaceMemberSelectContainer>
</DropdownMenuItemsContainer>
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};

View File

@ -1,37 +1,7 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Avatar } from 'twenty-ui';
import { MenuItem, MenuItemAvatar } from 'twenty-ui';
import { WorkspaceMember } from '~/generated-metadata/graphql';
const StyledEmptyState = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
height: ${({ theme }) => theme.spacing(8)};
justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledWorkspaceMemberItem = styled.div`
align-items: center;
cursor: pointer;
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
gap: ${({ theme }) => theme.spacing(2)};
min-width: ${({ theme }) => theme.spacing(45)};
padding: ${({ theme }) => theme.spacing(2)};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
`;
const StyledWorkspaceMemberName = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
`;
type RoleWorkspaceMemberPickerDropdownContentProps = {
loading: boolean;
searchFilter: string;
@ -49,34 +19,24 @@ export const RoleWorkspaceMemberPickerDropdownContent = ({
return null;
}
if (!filteredWorkspaceMembers?.length) {
return (
<StyledEmptyState>
{searchFilter
? t`No members matching this search`
: t`No more members to add`}
</StyledEmptyState>
);
if (!filteredWorkspaceMembers?.length && searchFilter?.length > 0) {
return <MenuItem disabled text={t`No Result`} />;
}
return (
<>
{filteredWorkspaceMembers.map((workspaceMember) => (
<StyledWorkspaceMemberItem
<MenuItemAvatar
key={workspaceMember.id}
onClick={() => onSelect(workspaceMember)}
aria-label={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
>
<Avatar
type="rounded"
size="md"
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
/>
<StyledWorkspaceMemberName>
{workspaceMember.name.firstName} {workspaceMember.name.lastName}
</StyledWorkspaceMemberName>
</StyledWorkspaceMemberItem>
avatar={{
type: 'rounded',
size: 'md',
placeholder: workspaceMember.name.firstName ?? '',
placeholderColorSeed: workspaceMember.id,
}}
text={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
/>
))}
</>
);

View File

@ -0,0 +1,6 @@
export type RolePermissionsObjectPermission = {
key: string;
label: string;
icon: React.ReactNode;
value: boolean;
};

View File

@ -0,0 +1,6 @@
export type RolePermissionsSettingPermission = {
key: string;
label: string;
type: string;
value: boolean;
};