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:
+162
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user