Role page various fixes 2 (#12416)

- Fix: AvatarURL signedPath for workspace members were not consistent
when queried multiple times and it was causing the frontend to wrongly
interpret this as a change in the deepEqual condition
- Use SaveAndCancel button to be consistent with data model page
- When applying all object permission changes, a "smarter" logic applies
and removes all permissions if read is unchecked for example
- Hide settings permissions when Settings All Access is toggled
This commit is contained in:
Weiko
2025-06-02 20:24:53 +02:00
committed by GitHub
parent e1a7fa3e5d
commit 8e710004ba
13 changed files with 139 additions and 69 deletions

View File

@ -28,11 +28,21 @@ export const SettingsRolesQueryEffect = () => {
snapshot, snapshot,
settingsPersistedRoleFamilyState(role.id), settingsPersistedRoleFamilyState(role.id),
); );
const currentDraftRole = getSnapshotValue(
snapshot,
settingsDraftRoleFamilyState(role.id),
);
if (isDeeplyEqual(role, persistedRole)) { if (isDeeplyEqual(role, persistedRole)) {
return; return;
} }
set(settingsDraftRoleFamilyState(role.id), role);
set(settingsPersistedRoleFamilyState(role.id), role); set(settingsPersistedRoleFamilyState(role.id), role);
if (!isDeeplyEqual(currentDraftRole, role)) {
set(settingsDraftRoleFamilyState(role.id), role);
}
}); });
}, },
[], [],

View File

@ -1,9 +1,12 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import React from 'react'; import React from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { import {
AppTooltip, AppTooltip,
Avatar, Avatar,
@ -62,6 +65,16 @@ export const SettingsRolesTableRow = ({ role }: SettingsRolesTableRowProps) => {
const { getIcon } = useIcons(); const { getIcon } = useIcons();
const Icon = getIcon(role.icon ?? 'IconUser'); const Icon = getIcon(role.icon ?? 'IconUser');
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const enrichedWorkspaceMembers = role.workspaceMembers
.map((workspaceMember) =>
currentWorkspaceMembers.find(
(member) => member.id === workspaceMember.id,
),
)
.filter(isDefined);
return ( return (
<StyledTableRow <StyledTableRow
key={role.id} key={role.id}
@ -85,7 +98,7 @@ export const SettingsRolesTableRow = ({ role }: SettingsRolesTableRowProps) => {
</TableCell> </TableCell>
<TableCell align={'right'}> <TableCell align={'right'}>
<StyledAvatarGroup> <StyledAvatarGroup>
{role.workspaceMembers.slice(0, 5).map((workspaceMember) => ( {enrichedWorkspaceMembers.slice(0, 5).map((workspaceMember) => (
<React.Fragment key={workspaceMember.id}> <React.Fragment key={workspaceMember.id}>
<div id={`avatar-${workspaceMember.id}`}> <div id={`avatar-${workspaceMember.id}`}>
<Avatar <Avatar

View File

@ -1,4 +1,7 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import {
CurrentWorkspaceMember,
currentWorkspaceMemberState,
} from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole'; import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole';
import { SettingsRoleAssignmentConfirmationModal } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal'; import { SettingsRoleAssignmentConfirmationModal } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal';
@ -28,11 +31,7 @@ import {
} from 'twenty-ui/display'; } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input'; import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout'; import { Section } from 'twenty-ui/layout';
import { import { Role, WorkspaceMember } from '~/generated-metadata/graphql';
Role,
SearchRecord,
WorkspaceMember,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID } from '../constants/RoleAssignmentConfirmationModalId'; import { ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID } from '../constants/RoleAssignmentConfirmationModalId';
import { SettingsRoleAssignmentTableRow } from './SettingsRoleAssignmentTableRow'; import { SettingsRoleAssignmentTableRow } from './SettingsRoleAssignmentTableRow';
@ -142,17 +141,14 @@ export const SettingsRoleAssignment = ({
}; };
const handleSelectWorkspaceMember = ( const handleSelectWorkspaceMember = (
workspaceMemberSearchRecord: SearchRecord, workspaceMember: CurrentWorkspaceMember,
) => { ) => {
const existingRole = workspaceMemberRoleMap.get( const existingRole = workspaceMemberRoleMap.get(workspaceMember.id);
workspaceMemberSearchRecord.recordId,
);
setSelectedWorkspaceMember({ setSelectedWorkspaceMember({
id: workspaceMemberSearchRecord.recordId, id: workspaceMember.id,
name: `${workspaceMemberSearchRecord.label}`, name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
role: existingRole, role: existingRole,
avatarUrl: workspaceMemberSearchRecord.imageUrl,
}); });
openModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID); openModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID);
closeDropdown(); closeDropdown();

View File

@ -1,7 +1,9 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { SettingsCard } from '@/settings/components/SettingsCard'; import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember'; import { SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { Avatar } from 'twenty-ui/display'; import { Avatar } from 'twenty-ui/display';
const StyledSettingsCardContainer = styled.div` const StyledSettingsCardContainer = styled.div`
@ -17,7 +19,13 @@ export const SettingsRoleAssignmentConfirmationModalSubtitle = ({
selectedWorkspaceMember, selectedWorkspaceMember,
onRoleClick, onRoleClick,
}: SettingsRoleAssignmentConfirmationModalSubtitleProps) => { }: SettingsRoleAssignmentConfirmationModalSubtitleProps) => {
const workspaceMemberName = selectedWorkspaceMember.name; const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const enrichedSelectedWorkspaceMember = currentWorkspaceMembers.find(
(member) => member.id === selectedWorkspaceMember.id,
);
const workspaceMemberName = `${enrichedSelectedWorkspaceMember?.name.firstName} ${enrichedSelectedWorkspaceMember?.name.lastName}`;
return ( return (
<> <>
@ -27,9 +35,9 @@ export const SettingsRoleAssignmentConfirmationModalSubtitle = ({
title={selectedWorkspaceMember.role?.label || ''} title={selectedWorkspaceMember.role?.label || ''}
Icon={ Icon={
<Avatar <Avatar
avatarUrl={selectedWorkspaceMember.avatarUrl} avatarUrl={enrichedSelectedWorkspaceMember?.avatarUrl}
placeholderColorSeed={selectedWorkspaceMember.id} placeholderColorSeed={enrichedSelectedWorkspaceMember?.id}
placeholder={selectedWorkspaceMember.name} placeholder={workspaceMemberName}
size="md" size="md"
type="rounded" type="rounded"
/> />

View File

@ -1,6 +1,8 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display'; import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { WorkspaceMember } from '~/generated-metadata/graphql'; import { WorkspaceMember } from '~/generated-metadata/graphql';
@ -35,15 +37,20 @@ type SettingsRoleAssignmentTableRowProps = {
export const SettingsRoleAssignmentTableRow = ({ export const SettingsRoleAssignmentTableRow = ({
workspaceMember, workspaceMember,
}: SettingsRoleAssignmentTableRowProps) => { }: SettingsRoleAssignmentTableRowProps) => {
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const enrichedWorkspaceMember = currentWorkspaceMembers.find(
(member) => member.id === workspaceMember.id,
);
return ( return (
<TableRow gridAutoColumns="2fr 4fr"> <TableRow gridAutoColumns="2fr 4fr">
<StyledTableCell> <StyledTableCell>
<StyledNameContainer> <StyledNameContainer>
<StyledIconWrapper> <StyledIconWrapper>
<Avatar <Avatar
avatarUrl={workspaceMember.avatarUrl} avatarUrl={enrichedWorkspaceMember?.avatarUrl}
placeholderColorSeed={workspaceMember.id} placeholderColorSeed={enrichedWorkspaceMember?.id}
placeholder={workspaceMember.name.firstName ?? ''} placeholder={enrichedWorkspaceMember?.name.firstName ?? ''}
type="rounded" type="rounded"
size="md" size="md"
/> />

View File

@ -1,3 +1,4 @@
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords'; import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent'; import { SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent';
@ -7,11 +8,10 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { ChangeEvent, useState } from 'react'; import { ChangeEvent, useState } from 'react';
import { SearchRecord } from '~/generated-metadata/graphql';
type SettingsRoleAssignmentWorkspaceMemberPickerDropdownProps = { type SettingsRoleAssignmentWorkspaceMemberPickerDropdownProps = {
excludedWorkspaceMemberIds: string[]; excludedWorkspaceMemberIds: string[];
onSelect: (workspaceMemberSearchRecord: SearchRecord) => void; onSelect: (workspaceMember: CurrentWorkspaceMember) => void;
}; };
export const SettingsRoleAssignmentWorkspaceMemberPickerDropdown = ({ export const SettingsRoleAssignmentWorkspaceMemberPickerDropdown = ({

View File

@ -1,4 +1,8 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { MenuItem, MenuItemAvatar } from 'twenty-ui/navigation'; import { MenuItem, MenuItemAvatar } from 'twenty-ui/navigation';
import { SearchRecord } from '~/generated-metadata/graphql'; import { SearchRecord } from '~/generated-metadata/graphql';
@ -6,7 +10,7 @@ type SettingsRoleAssignmentWorkspaceMemberPickerDropdownContentProps = {
loading: boolean; loading: boolean;
searchFilter: string; searchFilter: string;
filteredWorkspaceMembers: SearchRecord[]; filteredWorkspaceMembers: SearchRecord[];
onSelect: (workspaceMemberSearchRecord: SearchRecord) => void; onSelect: (workspaceMember: CurrentWorkspaceMember) => void;
}; };
export const SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent = ({ export const SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent = ({
@ -15,6 +19,8 @@ export const SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent = ({
filteredWorkspaceMembers, filteredWorkspaceMembers,
onSelect, onSelect,
}: SettingsRoleAssignmentWorkspaceMemberPickerDropdownContentProps) => { }: SettingsRoleAssignmentWorkspaceMemberPickerDropdownContentProps) => {
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
if (loading) { if (loading) {
return null; return null;
} }
@ -23,20 +29,28 @@ export const SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent = ({
return <MenuItem disabled text={t`No Results`} />; return <MenuItem disabled text={t`No Results`} />;
} }
const enrichedWorkspaceMembers = filteredWorkspaceMembers
.map((workspaceMember) =>
currentWorkspaceMembers.find(
(member) => member.id === workspaceMember.recordId,
),
)
.filter(isDefined);
return ( return (
<> <>
{filteredWorkspaceMembers.map((workspaceMember) => ( {enrichedWorkspaceMembers.map((workspaceMember) => (
<MenuItemAvatar <MenuItemAvatar
key={workspaceMember.recordId} key={workspaceMember.id}
onClick={() => onSelect(workspaceMember)} onClick={() => onSelect(workspaceMember)}
avatar={{ avatar={{
type: 'rounded', type: 'rounded',
size: 'md', size: 'md',
placeholder: workspaceMember.label ?? '', placeholder: workspaceMember?.name.firstName ?? '',
placeholderColorSeed: workspaceMember.recordId, placeholderColorSeed: workspaceMember?.id,
avatarUrl: workspaceMember.imageUrl, avatarUrl: workspaceMember?.avatarUrl,
}} }}
text={workspaceMember.label} text={workspaceMember?.name.firstName ?? ''}
/> />
))} ))}
</> </>

View File

@ -2,7 +2,6 @@ export type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember = {
id: string; id: string;
name: string; name: string;
role?: { id: string; label: string }; role?: { id: string; label: string };
avatarUrl?: string | null;
colorScheme?: string; colorScheme?: string;
userEmail?: string; userEmail?: string;
}; };

View File

@ -82,7 +82,13 @@ export const SettingsRolePermissionsObjectLevelSection = ({
...(draftRole.objectPermissions ?? []).filter( ...(draftRole.objectPermissions ?? []).filter(
(permission) => permission.objectMetadataId !== objectMetadataId, (permission) => permission.objectMetadataId !== objectMetadataId,
), ),
{ objectMetadataId, roleId }, {
objectMetadataId,
canReadObjectRecords: null,
canUpdateObjectRecords: null,
canSoftDeleteObjectRecords: null,
canDestroyObjectRecords: null,
},
], ],
})); }));
navigate(SettingsPath.RoleObjectLevel, { navigate(SettingsPath.RoleObjectLevel, {

View File

@ -47,6 +47,13 @@ export const SettingsRolePermissionsObjectsSection = ({
setSettingsDraftRole({ setSettingsDraftRole({
...settingsDraftRole, ...settingsDraftRole,
canReadAllObjectRecords: value, canReadAllObjectRecords: value,
...(value === false
? {
canUpdateAllObjectRecords: value,
canSoftDeleteAllObjectRecords: value,
canDestroyAllObjectRecords: value,
}
: {}),
}); });
}, },
}, },
@ -64,6 +71,11 @@ export const SettingsRolePermissionsObjectsSection = ({
setSettingsDraftRole({ setSettingsDraftRole({
...settingsDraftRole, ...settingsDraftRole,
canUpdateAllObjectRecords: value, canUpdateAllObjectRecords: value,
...(value === true
? {
canReadAllObjectRecords: value,
}
: {}),
}); });
}, },
}, },
@ -81,6 +93,11 @@ export const SettingsRolePermissionsObjectsSection = ({
setSettingsDraftRole({ setSettingsDraftRole({
...settingsDraftRole, ...settingsDraftRole,
canSoftDeleteAllObjectRecords: value, canSoftDeleteAllObjectRecords: value,
...(value === true
? {
canReadAllObjectRecords: value,
}
: {}),
}); });
}, },
}, },
@ -98,6 +115,11 @@ export const SettingsRolePermissionsObjectsSection = ({
setSettingsDraftRole({ setSettingsDraftRole({
...settingsDraftRole, ...settingsDraftRole,
canDestroyAllObjectRecords: value, canDestroyAllObjectRecords: value,
...(value === true
? {
canReadAllObjectRecords: value,
}
: {}),
}); });
}, },
}, },

View File

@ -16,7 +16,7 @@ import {
IconSettings, IconSettings,
IconUsers, IconUsers,
} from 'twenty-ui/display'; } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout'; import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
import { import {
FeatureFlagKey, FeatureFlagKey,
SettingPermissionType, SettingPermissionType,
@ -112,19 +112,30 @@ export const SettingsRolePermissionsSettingsSection = ({
/> />
</StyledCard> </StyledCard>
)} )}
<StyledTable> <AnimatedExpandableContainer
<SettingsRolePermissionsSettingsTableHeader /> isExpanded={!settingsDraftRole.canUpdateAllSettings}
<StyledTableRows> dimension="height"
{settingsPermissionsConfig.map((permission) => ( animationDurations={{
<SettingsRolePermissionsSettingsTableRow opacity: 0.2,
key={permission.key} size: 0.4,
roleId={roleId} }}
permission={permission} mode="scroll-height"
isEditable={isEditable} containAnimation={false}
/> >
))} <StyledTable>
</StyledTableRows> <SettingsRolePermissionsSettingsTableHeader />
</StyledTable> <StyledTableRows>
{settingsPermissionsConfig.map((permission) => (
<SettingsRolePermissionsSettingsTableRow
key={permission.key}
roleId={roleId}
permission={permission}
isEditable={isEditable}
/>
))}
</StyledTableRows>
</StyledTable>
</AnimatedExpandableContainer>
</Section> </Section>
); );
}; };

View File

@ -1,3 +1,4 @@
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery'; import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole'; import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole';
@ -22,7 +23,6 @@ import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display'; import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { import {
FeatureFlagKey, FeatureFlagKey,
@ -281,16 +281,11 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
}, },
]} ]}
actionButton={ actionButton={
isDirty && ( <SaveAndCancelButtons
<Button onSave={handleSave}
title={isCreateMode ? t`Create` : t`Save`} onCancel={() => navigateSettings(SettingsPath.Roles)}
variant="primary" isSaveDisabled={!isRoleEditable || !isDirty}
size="small" />
accent="blue"
onClick={handleSave}
disabled={!isRoleEditable}
/>
)
} }
> >
<SettingsPageContainer> <SettingsPageContainer>

View File

@ -188,17 +188,6 @@ export class RoleResolver {
workspace.id, workspace.id,
); );
await Promise.all(
workspaceMembers.map(async (workspaceMember) => {
if (workspaceMember && workspaceMember.avatarUrl) {
workspaceMember.avatarUrl = this.fileService.signFileUrl({
url: workspaceMember.avatarUrl,
workspaceId: workspace.id,
});
}
}),
);
return workspaceMembers; return workspaceMembers;
} }