From eecc7aaed3b11891166cbf2e093f7ef206c76811 Mon Sep 17 00:00:00 2001
From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com>
Date: Tue, 2 Dec 2025 19:21:17 +0500
Subject: [PATCH] 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.
Therefore, I added a Select component for now and would love some
clarity on what's ideal.
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.
---
.../components/MemberPermissionsTab.tsx | 162 ++++++++++++++++++
.../members/hooks/useWorkspaceMemberRoles.ts | 20 +++
.../members/SettingsWorkspaceMember.tsx | 141 +++++++++------
3 files changed, 269 insertions(+), 54 deletions(-)
create mode 100644 packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx
create mode 100644 packages/twenty-front/src/modules/settings/members/hooks/useWorkspaceMemberRoles.ts
diff --git a/packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx b/packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx
new file mode 100644
index 0000000000..4f332ca790
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/members/components/MemberPermissionsTab.tsx
@@ -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(
+ 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 (
+ {t`No role assigned to this member`}
+ );
+ }
+
+ const oldRoleLabel = primaryRole.label;
+ const newRoleLabel = pendingRole?.label || '';
+
+ return (
+ <>
+
+
+ {pendingRole && (
+
+ )}
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/members/hooks/useWorkspaceMemberRoles.ts b/packages/twenty-front/src/modules/settings/members/hooks/useWorkspaceMemberRoles.ts
new file mode 100644
index 0000000000..e10ab4136e
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/members/hooks/useWorkspaceMemberRoles.ts
@@ -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,
+ };
+};
diff --git a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx
index 3540fd4f26..99a7226234 100644
--- a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx
+++ b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx
@@ -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({
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({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
@@ -147,55 +161,74 @@ export const SettingsWorkspaceMember = () => {
});
};
- if (loading || !member) return null;
+ const isLoading = loading || rolesLoading || !member;
return (
-
-
- {/*
+
+ {isLoading ? null : (
+ */}
+ >
+
+
- {/* {activeTabId === SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.INFOS && ( */}
- openModal(DELETE_MEMBER_MODAL_ID)}
- />
- {/* )} */}
-
+ {activeTabId === SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.INFOS && (
+ openModal(DELETE_MEMBER_MODAL_ID)}
+ />
+ )}
-
-
+ {activeTabId ===
+ SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.PERMISSIONS && (
+
+ )}
+
+
+
+
+ )}
+ >
);
};