Workspace member permission tab. (#16233)

Introduce a read-only view of permissions similar to agent roles tab.
The role selector in the following image feels as if it would lead to a
new page, which is not what we want.

<img width="1281" height="813" alt="Permissions (If easier V1)"
src="https://github.com/user-attachments/assets/7b272f5a-40ef-43ad-83d8-f9e588b1cd6e"
/>

Therefore, I added a Select component for now and would love some
clarity on what's ideal.

<img width="554" height="651" alt="image"
src="https://github.com/user-attachments/assets/91575208-66b1-4ed1-86cd-f3aa528ad0dc"
/>

Also, the "Add Rule" button changes to enabled when we switch the role
from `Admin` to `Member` and clicking it redirects to
`/settings/roles/:role-id/add-object-permission`. Do we want to disable
the button completely?

One final thing: SettingsAgentRoleTab already re-uses
SettingsRolePermissions. I created MemberPermissionsTab to do the same.
I don't think we need an abstracted shared component here since both
tabs rely on the same shared child anyway. However, if we need a
refactor, I can use some direction on how the code should look.

Creating this PR as draft since I think there might be a couple
suggested changes on this.
This commit is contained in:
Abdullah.
2025-12-02 19:21:17 +05:00
committed by GitHub
parent 12babba6f6
commit eecc7aaed3
3 changed files with 269 additions and 54 deletions
@@ -0,0 +1,162 @@
import { SettingsRolePermissions } from '@/settings/roles/role-permissions/components/SettingsRolePermissions';
import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconArrowUpRight,
IconUser,
useIcons,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useUpdateWorkspaceMemberRoleMutation } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const CONFIRM_ROLE_CHANGE_MODAL_ID = 'confirm-role-change-modal';
const StyledNoRoleContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(8)};
`;
const StyledRoleContainer = styled.div`
align-items: flex-end;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StyledRoleSelector = styled.div`
flex: 1;
`;
type MemberPermissionsTabProps = {
member: WorkspaceMember;
roles: RoleWithPartialMembers[];
allRoles: RoleWithPartialMembers[];
};
export const MemberPermissionsTab = ({
member,
roles,
allRoles,
}: MemberPermissionsTabProps) => {
const primaryRole = roles?.[0];
const { getIcon } = useIcons();
const navigateSettings = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal } = useModal();
const [pendingRole, setPendingRole] = useState<RoleWithPartialMembers | null>(
null,
);
const [updateWorkspaceMemberRoleMutation] =
useUpdateWorkspaceMemberRoleMutation();
const rolesOptions =
allRoles
?.filter((role) => role.canBeAssignedToUsers)
.map((role) => ({
label: role.label,
value: role.id,
Icon: getIcon(role.icon) ?? IconUser,
})) || [];
const handleRoleChangeRequest = (newRoleId: string) => {
const newRole = allRoles.find((role) => role.id === newRoleId);
if (!newRole || newRoleId === primaryRole?.id) return;
setPendingRole(newRole);
openModal(CONFIRM_ROLE_CHANGE_MODAL_ID);
};
const handleConfirmRoleChange = async () => {
if (!member?.id || !pendingRole) return;
try {
await updateWorkspaceMemberRoleMutation({
variables: {
workspaceMemberId: member.id,
roleId: pendingRole.id,
},
refetchQueries: ['GetRoles'],
});
enqueueSuccessSnackBar({ message: t`Role updated successfully` });
} catch (error) {
enqueueErrorSnackBar({
message:
error instanceof Error ? error.message : t`Failed to update role`,
});
} finally {
setPendingRole(null);
}
};
const handleOpenRole = () => {
if (isDefined(primaryRole)) {
navigateSettings(SettingsPath.RoleDetail, { roleId: primaryRole.id });
}
};
if (!primaryRole) {
return (
<StyledNoRoleContainer>{t`No role assigned to this member`}</StyledNoRoleContainer>
);
}
const oldRoleLabel = primaryRole.label;
const newRoleLabel = pendingRole?.label || '';
return (
<>
<Section>
<H2Title
title={t`Role`}
description={t`Customize what this user can view and perform`}
/>
<StyledRoleContainer>
<StyledRoleSelector>
<Select
dropdownId="member-role-select"
options={rolesOptions}
value={primaryRole.id}
onChange={handleRoleChangeRequest}
withSearchInput
fullWidth
/>
</StyledRoleSelector>
<Button
Icon={IconArrowUpRight}
title={t`Open in Roles`}
variant="secondary"
onClick={handleOpenRole}
/>
</StyledRoleContainer>
<SettingsRolePermissions roleId={primaryRole.id} isEditable={false} />
</Section>
{pendingRole && (
<ConfirmationModal
modalId={CONFIRM_ROLE_CHANGE_MODAL_ID}
title={t`Confirm role update`}
subtitle={t`Are you sure you want to update the role of this user from "${oldRoleLabel}" to "${newRoleLabel}"?`}
onConfirmClick={handleConfirmRoleChange}
confirmButtonText={t`Update role`}
confirmButtonAccent="blue"
/>
)}
</>
);
};
@@ -0,0 +1,20 @@
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
import { useRecoilValue } from 'recoil';
export const useWorkspaceMemberRoles = (workspaceMemberId: string) => {
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState);
const roles = workspaceMemberId
? settingsAllRoles.filter((role) =>
role.workspaceMembers.some((member) => member.id === workspaceMemberId),
)
: [];
return {
roles,
allRoles: settingsAllRoles,
loading: settingsRolesIsLoading,
};
};
@@ -6,20 +6,27 @@ import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconInfoCircle, IconLockOpen } from 'twenty-ui/display';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isImpersonatingState } from '@/auth/states/isImpersonatingState';
import { MemberInfosTab } from '@/settings/members/components/MemberInfosTab';
import { MemberPermissionsTab } from '@/settings/members/components/MemberPermissionsTab';
import { useWorkspaceMemberRoles } from '@/settings/members/hooks/useWorkspaceMemberRoles';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import {
useDeleteUserWorkspaceMutation,
@@ -27,12 +34,13 @@ import {
} from '~/generated-metadata/graphql';
import { PermissionFlagType } from '~/generated/graphql';
// const SETTINGS_WORKSPACE_MEMBER_TABS = {
// COMPONENT_INSTANCE_ID: 'settings-workspace-member-tabs',
// TABS_IDS: {
// INFOS: 'infos',
// },
// };
const SETTINGS_WORKSPACE_MEMBER_TABS = {
COMPONENT_INSTANCE_ID: 'settings-workspace-member-tabs',
TABS_IDS: {
INFOS: 'infos',
PERMISSIONS: 'permissions',
},
};
const DELETE_MEMBER_MODAL_ID = 'workspace-member-delete-modal';
@@ -48,6 +56,12 @@ export const SettingsWorkspaceMember = () => {
const canImpersonate =
useHasPermissionFlag(PermissionFlagType.IMPERSONATE) && !isImpersonating;
const {
roles,
allRoles,
loading: rolesLoading,
} = useWorkspaceMemberRoles(workspaceMemberId);
const { record: member, loading } = useFindOneRecord<WorkspaceMember>({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectRecordId: workspaceMemberId,
@@ -60,11 +74,11 @@ export const SettingsWorkspaceMember = () => {
},
});
// const tabListComponentId = `${SETTINGS_WORKSPACE_MEMBER_TABS.COMPONENT_INSTANCE_ID}-${workspaceMemberId}`;
// const activeTabId = useRecoilComponentValue(
// activeTabIdComponentState,
// tabListComponentId,
// );
const tabListComponentId = `${SETTINGS_WORKSPACE_MEMBER_TABS.COMPONENT_INSTANCE_ID}-${workspaceMemberId}`;
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
tabListComponentId,
);
const { updateOneRecord } = useUpdateOneRecord<WorkspaceMember>({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
@@ -147,55 +161,74 @@ export const SettingsWorkspaceMember = () => {
});
};
if (loading || !member) return null;
const isLoading = loading || rolesLoading || !member;
return (
<SubMenuTopBarContainer
title={`${member.name.firstName} ${member.name.lastName}`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Members`,
href: getSettingsPath(SettingsPath.WorkspaceMembersPage),
},
{
children: `${member.name.firstName} ${member.name.lastName}`,
},
]}
>
<SettingsPageContainer>
{/* <TabList
tabs={[
<>
<SettingsRolesQueryEffect />
{isLoading ? null : (
<SubMenuTopBarContainer
title={`${member.name.firstName} ${member.name.lastName}`}
links={[
{
id: SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.INFOS,
title: t`Infos`,
Icon: IconInfoCircle,
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Members`,
href: getSettingsPath(SettingsPath.WorkspaceMembersPage),
},
{
children: `${member.name.firstName} ${member.name.lastName}`,
},
]}
componentInstanceId={tabListComponentId}
/> */}
>
<SettingsPageContainer>
<TabList
tabs={[
{
id: SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.INFOS,
title: t`Infos`,
Icon: IconInfoCircle,
},
{
id: SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.PERMISSIONS,
title: t`Permissions`,
Icon: IconLockOpen,
},
]}
componentInstanceId={tabListComponentId}
/>
{/* {activeTabId === SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.INFOS && ( */}
<MemberInfosTab
member={member}
onImpersonate={canImpersonate ? handleImpersonate : undefined}
onNameChange={debouncedUpdateName}
onDelete={() => openModal(DELETE_MEMBER_MODAL_ID)}
/>
{/* )} */}
</SettingsPageContainer>
{activeTabId === SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.INFOS && (
<MemberInfosTab
member={member}
onImpersonate={canImpersonate ? handleImpersonate : undefined}
onNameChange={debouncedUpdateName}
onDelete={() => openModal(DELETE_MEMBER_MODAL_ID)}
/>
)}
<ConfirmationModal
modalId={DELETE_MEMBER_MODAL_ID}
title={t`Remove member from workspace`}
subtitle={t`This action cannot be undone. This will permanently remove this member from this workspace and remove them from all their assignments.`}
onConfirmClick={handleDeleteMember}
confirmButtonText={t`Remove member`}
loading={isDeleting}
/>
</SubMenuTopBarContainer>
{activeTabId ===
SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.PERMISSIONS && (
<MemberPermissionsTab
member={member}
roles={roles}
allRoles={allRoles}
/>
)}
</SettingsPageContainer>
<ConfirmationModal
modalId={DELETE_MEMBER_MODAL_ID}
title={t`Remove member from workspace`}
subtitle={t`This action cannot be undone. This will permanently remove this member from this workspace and remove them from all their assignments.`}
onConfirmClick={handleDeleteMember}
confirmButtonText={t`Remove member`}
loading={isDeleting}
/>
</SubMenuTopBarContainer>
)}
</>
);
};