add role update (#11217)

## Context
This PR introduces the new Create and Edit role components, behind the
PERMISSIONS_ENABLED_V2 feature flag.
This commit is contained in:
Weiko
2025-03-31 17:57:14 +02:00
committed by GitHub
parent 3c9bf2294f
commit 06ff16e086
58 changed files with 1527 additions and 624 deletions

View File

@ -380,6 +380,7 @@ export type CreateRoleInput = {
canUpdateAllSettings?: InputMaybe<Scalars['Boolean']['input']>;
description?: InputMaybe<Scalars['String']['input']>;
icon?: InputMaybe<Scalars['String']['input']>;
id?: InputMaybe<Scalars['String']['input']>;
label: Scalars['String']['input'];
};
@ -515,6 +516,7 @@ export enum EnvironmentVariablesGroup {
GoogleAuth = 'GoogleAuth',
LLM = 'LLM',
Logging = 'Logging',
Metering = 'Metering',
MicrosoftAuth = 'MicrosoftAuth',
Other = 'Other',
RateLimiting = 'RateLimiting',
@ -557,7 +559,6 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled',
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',

View File

@ -316,6 +316,7 @@ export type CreateRoleInput = {
canUpdateAllSettings?: InputMaybe<Scalars['Boolean']>;
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['String']>;
label: Scalars['String'];
};
@ -446,6 +447,7 @@ export enum EnvironmentVariablesGroup {
GoogleAuth = 'GoogleAuth',
LLM = 'LLM',
Logging = 'Logging',
Metering = 'Metering',
MicrosoftAuth = 'MicrosoftAuth',
Other = 'Other',
RateLimiting = 'RateLimiting',
@ -488,7 +490,6 @@ export type FeatureFlag = {
};
export enum FeatureFlagKey {
IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled',
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',
@ -2594,6 +2595,20 @@ export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', upda
export type RoleFragmentFragment = { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean };
export type CreateOneRoleMutationVariables = Exact<{
createRoleInput: CreateRoleInput;
}>;
export type CreateOneRoleMutation = { __typename?: 'Mutation', createOneRole: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } };
export type UpdateOneRoleMutationVariables = Exact<{
updateRoleInput: UpdateRoleInput;
}>;
export type UpdateOneRoleMutation = { __typename?: 'Mutation', updateOneRole: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } };
export type UpdateWorkspaceMemberRoleMutationVariables = Exact<{
workspaceMemberId: Scalars['String'];
roleId: Scalars['String'];
@ -4605,6 +4620,72 @@ export function useUpdateLabPublicFeatureFlagMutation(baseOptions?: Apollo.Mutat
export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType<typeof useUpdateLabPublicFeatureFlagMutation>;
export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult<UpdateLabPublicFeatureFlagMutation>;
export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const CreateOneRoleDocument = gql`
mutation CreateOneRole($createRoleInput: CreateRoleInput!) {
createOneRole(createRoleInput: $createRoleInput) {
...RoleFragment
}
}
${RoleFragmentFragmentDoc}`;
export type CreateOneRoleMutationFn = Apollo.MutationFunction<CreateOneRoleMutation, CreateOneRoleMutationVariables>;
/**
* __useCreateOneRoleMutation__
*
* To run a mutation, you first call `useCreateOneRoleMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateOneRoleMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createOneRoleMutation, { data, loading, error }] = useCreateOneRoleMutation({
* variables: {
* createRoleInput: // value for 'createRoleInput'
* },
* });
*/
export function useCreateOneRoleMutation(baseOptions?: Apollo.MutationHookOptions<CreateOneRoleMutation, CreateOneRoleMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateOneRoleMutation, CreateOneRoleMutationVariables>(CreateOneRoleDocument, options);
}
export type CreateOneRoleMutationHookResult = ReturnType<typeof useCreateOneRoleMutation>;
export type CreateOneRoleMutationResult = Apollo.MutationResult<CreateOneRoleMutation>;
export type CreateOneRoleMutationOptions = Apollo.BaseMutationOptions<CreateOneRoleMutation, CreateOneRoleMutationVariables>;
export const UpdateOneRoleDocument = gql`
mutation UpdateOneRole($updateRoleInput: UpdateRoleInput!) {
updateOneRole(updateRoleInput: $updateRoleInput) {
...RoleFragment
}
}
${RoleFragmentFragmentDoc}`;
export type UpdateOneRoleMutationFn = Apollo.MutationFunction<UpdateOneRoleMutation, UpdateOneRoleMutationVariables>;
/**
* __useUpdateOneRoleMutation__
*
* To run a mutation, you first call `useUpdateOneRoleMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateOneRoleMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateOneRoleMutation, { data, loading, error }] = useUpdateOneRoleMutation({
* variables: {
* updateRoleInput: // value for 'updateRoleInput'
* },
* });
*/
export function useUpdateOneRoleMutation(baseOptions?: Apollo.MutationHookOptions<UpdateOneRoleMutation, UpdateOneRoleMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateOneRoleMutation, UpdateOneRoleMutationVariables>(UpdateOneRoleDocument, options);
}
export type UpdateOneRoleMutationHookResult = ReturnType<typeof useUpdateOneRoleMutation>;
export type UpdateOneRoleMutationResult = Apollo.MutationResult<UpdateOneRoleMutation>;
export type UpdateOneRoleMutationOptions = Apollo.BaseMutationOptions<UpdateOneRoleMutation, UpdateOneRoleMutationVariables>;
export const UpdateWorkspaceMemberRoleDocument = gql`
mutation UpdateWorkspaceMemberRole($workspaceMemberId: String!, $roleId: String!) {
updateWorkspaceMemberRole(

View File

@ -301,6 +301,12 @@ const SettingsRoles = lazy(() =>
})),
);
const SettingsRoleCreate = lazy(() =>
import('~/pages/settings/roles/SettingsRoleCreate').then((module) => ({
default: module.SettingsRoleCreate,
})),
);
const SettingsRoleEdit = lazy(() =>
import('~/pages/settings/roles/SettingsRoleEdit').then((module) => ({
default: module.SettingsRoleEdit,
@ -392,6 +398,10 @@ export const SettingsRoutes = ({
>
<Route path={SettingsPath.Roles} element={<SettingsRoles />} />
<Route path={SettingsPath.RoleDetail} element={<SettingsRoleEdit />} />
<Route
path={SettingsPath.RoleCreate}
element={<SettingsRoleCreate />}
/>
</Route>
<Route
element={

View File

@ -62,10 +62,10 @@ import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/state
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { i18n } from '@lingui/core';
import { useSearchParams } from 'react-router-dom';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);

View File

@ -1,49 +0,0 @@
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { RolesTableHeader } from '@/settings/roles/components/RolesTableHeader';
import { RolesTableRow } from '@/settings/roles/components/RolesTableRow';
import { Button, H2Title, IconPlus, Section } from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
const StyledCreateRoleSection = styled(Section)`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export const Roles = ({ roles }: { roles: Role[] }) => {
return (
<Section>
<H2Title
title={t`All roles`}
description={t`Assign roles to specify each member's access permissions`}
/>
<Table>
<RolesTableHeader />
<StyledTableRows>
{roles.map((role) => (
<RolesTableRow key={role.id} role={role} />
))}
</StyledTableRows>
</Table>
<StyledCreateRoleSection>
<Button
Icon={IconPlus}
title={t`Create Role`}
variant="secondary"
size="small"
soon
/>
</StyledCreateRoleSection>
</Section>
);
};

View File

@ -0,0 +1,44 @@
import { H3Title } from 'twenty-ui';
import { SettingsPath } from '@/types/SettingsPath';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsRoleDefaultRole } from '@/settings/roles/components/SettingsRolesDefaultRole';
import { SettingsRolesList } from '@/settings/roles/components/SettingsRolesList';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
export const SettingsRolesContainer = () => {
const { t } = useLingui();
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState);
if (settingsRolesIsLoading) {
return null;
}
return (
<SubMenuTopBarContainer
title={<H3Title title={t`Roles`} />}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: <Trans>Roles</Trans> },
]}
>
<SettingsPageContainer>
<SettingsRolesList />
<SettingsRoleDefaultRole roles={settingsAllRoles} />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -6,15 +6,15 @@ import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsO
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Card, H2Title, IconUserPin, Section } from 'twenty-ui';
import {
Role,
UpdateWorkspaceMutation,
useUpdateWorkspaceMutation,
} from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
export const RolesDefaultRole = ({ roles }: { roles: Role[] }) => {
export const SettingsRoleDefaultRole = ({ roles }: { roles: Role[] }) => {
const [updateWorkspace] = useUpdateWorkspaceMutation();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(

View File

@ -0,0 +1,72 @@
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { SettingsRolesTableHeader } from '@/settings/roles/components/SettingsRolesTableHeader';
import { SettingsRolesTableRow } from '@/settings/roles/components/SettingsRolesTableRow';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { SettingsPath } from '@/types/SettingsPath';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { Button, H2Title, IconPlus, Section } from 'twenty-ui';
import { FeatureFlagKey } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const StyledCreateRoleSection = styled(Section)`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledNoRoles = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const SettingsRolesList = () => {
const navigateSettings = useNavigateSettings();
const isPermissionsV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsPermissionsV2Enabled,
);
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
return (
<Section>
<H2Title
title={t`All roles`}
description={t`Assign roles to specify each member's access permissions`}
/>
<Table>
<SettingsRolesTableHeader />
<StyledTableRows>
{settingsAllRoles.length === 0 ? (
<StyledNoRoles>{t`No roles found`}</StyledNoRoles>
) : (
settingsAllRoles.map((role) => (
<SettingsRolesTableRow key={role.id} role={role} />
))
)}
</StyledTableRows>
</Table>
<StyledCreateRoleSection>
<Button
Icon={IconPlus}
title={t`Create Role`}
variant="secondary"
size="small"
soon={!isPermissionsV2Enabled}
disabled={!isPermissionsV2Enabled}
onClick={() => navigateSettings(SettingsPath.RoleCreate)}
/>
</StyledCreateRoleSection>
</Section>
);
};

View File

@ -0,0 +1,43 @@
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { settingsRoleIdsState } from '@/settings/roles/states/settingsRoleIdsState';
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
import { useEffect } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Role, useGetRolesQuery } from '~/generated/graphql';
export const SettingsRolesQueryEffect = () => {
const { data, loading } = useGetRolesQuery({
fetchPolicy: 'network-only',
});
const setSettingsRolesIsLoading = useSetRecoilState(
settingsRolesIsLoadingState,
);
const populateRoles = useRecoilCallback(
({ set }) =>
(roles: Role[]) => {
const roleIds = roles.map((role) => role.id);
set(settingsRoleIdsState, roleIds);
roles.forEach((role) => {
set(settingsPersistedRoleFamilyState(role.id), role);
});
},
[],
);
useEffect(() => {
setSettingsRolesIsLoading(loading);
if (!loading) {
const roles = data?.getRoles;
if (!isDefined(roles)) {
return;
}
populateRoles(roles);
}
}, [data, loading, populateRoles, setSettingsRolesIsLoading]);
return null;
};

View File

@ -3,7 +3,7 @@ import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { Trans } from '@lingui/react/macro';
export const RolesTableHeader = () => {
export const SettingsRolesTableHeader = () => {
return (
<Table>
<TableRow gridAutoColumns="332px 3fr 2fr 1fr">

View File

@ -52,7 +52,7 @@ const StyledTableRow = styled(TableRow)`
}
`;
export const RolesTableRow = ({ role }: { role: Role }) => {
export const SettingsRolesTableRow = ({ role }: { role: Role }) => {
const theme = useTheme();
const navigateSettings = useNavigateSettings();

View File

@ -1,24 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { Roles } from '@/settings/roles/components/Roles';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
const meta: Meta<typeof Roles> = {
title: 'Modules/Settings/Roles/Roles',
component: Roles,
decorators: [ComponentDecorator, I18nFrontDecorator, RouterDecorator],
parameters: {
maxWidth: 800,
},
};
export default meta;
type Story = StoryObj<typeof Roles>;
export const Default: Story = {
args: {
roles: getRolesMock(),
},
};

View File

@ -1,43 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { RolesDefaultRole } from '@/settings/roles/components/RolesDefaultRole';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
import { mockCurrentWorkspace } from '~/testing/mock-data/users';
const rolesMock = getRolesMock();
const RolesDefaultRoleWrapper = () => {
return (
<RecoilRoot
initializeState={(snapshot) => {
snapshot.set(currentWorkspaceState, {
...mockCurrentWorkspace,
defaultRole: rolesMock[1],
});
}}
>
<RolesDefaultRole roles={rolesMock} />
</RecoilRoot>
);
};
const meta: Meta<typeof RolesDefaultRoleWrapper> = {
title: 'Modules/Settings/Roles/RolesDefaultRole',
component: RolesDefaultRoleWrapper,
decorators: [ComponentDecorator, I18nFrontDecorator],
parameters: {
maxWidth: 800,
},
};
export default meta;
type Story = StoryObj<typeof RolesDefaultRoleWrapper>;
export const Default: Story = {
args: {
roles: rolesMock,
},
};

View File

@ -0,0 +1,11 @@
import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { gql } from '@apollo/client';
export const CREATE_ROLE = gql`
${ROLE_FRAGMENT}
mutation CreateOneRole($createRoleInput: CreateRoleInput!) {
createOneRole(createRoleInput: $createRoleInput) {
...RoleFragment
}
}
`;

View File

@ -0,0 +1,11 @@
import { ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/roleFragment';
import { gql } from '@apollo/client';
export const UPDATE_ROLE = gql`
${ROLE_FRAGMENT}
mutation UpdateOneRole($updateRoleInput: UpdateRoleInput!) {
updateOneRole(updateRoleInput: $updateRoleInput) {
...RoleFragment
}
}
`;

View File

@ -0,0 +1,105 @@
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { useRecoilState, useSetRecoilState } from 'recoil';
import {
useUpdateWorkspaceMemberRoleMutation,
WorkspaceMember,
} from '~/generated/graphql';
type AddWorkspaceMemberToRoleAndUpdateStateParams = {
workspaceMemberId: string;
};
type UpdateWorkspaceMemberRoleDraftStateParams = {
workspaceMember: WorkspaceMember;
};
type AddWorkspaceMembersToRoleParams = {
roleId: string;
workspaceMemberIds: string[];
};
export const useUpdateWorkspaceMemberRole = (roleId: string) => {
const setSettingsPersistedRole = useSetRecoilState(
settingsPersistedRoleFamilyState(roleId),
);
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const [updateWorkspaceMemberRoleMutation] =
useUpdateWorkspaceMemberRoleMutation();
const updateWorkspaceMemberRoleDraftState = ({
workspaceMember,
}: UpdateWorkspaceMemberRoleDraftStateParams) => {
setSettingsDraftRole({
...settingsDraftRole,
workspaceMembers: [
...settingsDraftRole.workspaceMembers,
{
id: workspaceMember.id,
name: workspaceMember.name,
colorScheme: workspaceMember.colorScheme,
userEmail: workspaceMember.userEmail,
},
],
});
};
const addWorkspaceMemberToRoleAndUpdateState = async ({
workspaceMemberId,
}: AddWorkspaceMemberToRoleAndUpdateStateParams) => {
const { data } = await updateWorkspaceMemberRoleMutation({
variables: {
workspaceMemberId,
roleId,
},
});
if (data?.updateWorkspaceMemberRole !== undefined) {
const updatedWorkspaceMember = data.updateWorkspaceMemberRole;
const updatedWorkspaceMembers = [
...settingsDraftRole.workspaceMembers,
{
id: updatedWorkspaceMember.id,
name: updatedWorkspaceMember.name,
colorScheme: updatedWorkspaceMember.colorScheme,
userEmail: updatedWorkspaceMember.userEmail,
},
];
const updatedRole = {
...settingsDraftRole,
workspaceMembers: updatedWorkspaceMembers,
};
setSettingsPersistedRole(updatedRole);
setSettingsDraftRole(updatedRole);
}
return data?.updateWorkspaceMemberRole;
};
const addWorkspaceMembersToRole = async ({
roleId,
workspaceMemberIds,
}: AddWorkspaceMembersToRoleParams) => {
await Promise.all(
workspaceMemberIds.map((workspaceMemberId) =>
updateWorkspaceMemberRoleMutation({
variables: {
roleId,
workspaceMemberId,
},
}),
),
);
};
return {
addWorkspaceMemberToRoleAndUpdateState,
updateWorkspaceMemberRoleDraftState,
addWorkspaceMembersToRole,
};
};

View File

@ -1,8 +1,12 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { RoleAssignmentTableHeader } from '@/settings/roles/role-assignment/components/RoleAssignmentTableHeader';
import { RoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/RoleAssignmentWorkspaceMemberPickerDropdown';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole';
import { SettingsRoleAssignmentConfirmationModal } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal';
import { SettingsRoleAssignmentTableHeader } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentTableHeader';
import { SettingsRoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown';
import { SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsPath } from '@/types/SettingsPath';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@ -26,14 +30,8 @@ import {
SearchRecord,
WorkspaceMember,
} from '~/generated-metadata/graphql';
import {
GetRolesDocument,
useGetRolesQuery,
useUpdateWorkspaceMemberRoleMutation,
} from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { RoleAssignmentConfirmationModal } from './RoleAssignmentConfirmationModal';
import { RoleAssignmentTableRow } from './RoleAssignmentTableRow';
import { SettingsRoleAssignmentTableRow } from './SettingsRoleAssignmentTableRow';
const StyledAssignToMemberContainer = styled.div`
display: flex;
@ -67,43 +65,51 @@ const StyledNoMembers = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
type RoleAssignmentProps = {
role: Pick<Role, 'id' | 'label' | 'canUpdateAllSettings'> & {
workspaceMembers: Array<WorkspaceMember>;
};
type SettingsRoleAssignmentProps = {
roleId: string;
isCreateMode?: boolean;
};
export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
export const SettingsRoleAssignment = ({
roleId,
isCreateMode,
}: SettingsRoleAssignmentProps) => {
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const navigateSettings = useNavigateSettings();
const [updateWorkspaceMemberRole] = useUpdateWorkspaceMemberRoleMutation({
refetchQueries: [GetRolesDocument],
});
const {
addWorkspaceMemberToRoleAndUpdateState,
updateWorkspaceMemberRoleDraftState,
} = useUpdateWorkspaceMemberRole(roleId);
const [confirmationModalIsOpen, setConfirmationModalIsOpen] =
useState<boolean>(false);
const [selectedWorkspaceMember, setSelectedWorkspaceMember] =
useState<RoleAssignmentConfirmationModalSelectedWorkspaceMember | null>(
useState<SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember | null>(
null,
);
const { data: rolesData } = useGetRolesQuery();
const { closeDropdown } = useDropdown('role-member-select');
const [searchFilter, setSearchFilter] = useState('');
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
const workspaceMemberRoleMap = new Map<
string,
{ id: string; label: string }
>();
rolesData?.getRoles?.forEach((role) => {
role.workspaceMembers.forEach((member) => {
settingsAllRoles.forEach((role: Role) => {
role.workspaceMembers.forEach((member: WorkspaceMember) => {
workspaceMemberRoleMap.set(member.id, { id: role.id, label: role.label });
});
});
const filteredWorkspaceMembers = !searchFilter
? role.workspaceMembers
: role.workspaceMembers.filter((member) => {
? settingsDraftRole.workspaceMembers
: settingsDraftRole.workspaceMembers.filter((member) => {
const searchTerm = searchFilter.toLowerCase();
const firstName = member.name.firstName?.toLowerCase() || '';
const lastName = member.name.lastName?.toLowerCase() || '';
@ -116,7 +122,7 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
);
});
const assignedWorkspaceMemberIds = role.workspaceMembers.map(
const assignedWorkspaceMemberIds = settingsDraftRole.workspaceMembers.map(
(workspaceMember) => workspaceMember.id,
);
@ -153,12 +159,28 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
const handleConfirm = async () => {
if (!selectedWorkspaceMember || !confirmationModalIsOpen) return;
await updateWorkspaceMemberRole({
variables: {
if (!isCreateMode) {
await addWorkspaceMemberToRoleAndUpdateState({
workspaceMemberId: selectedWorkspaceMember.id,
roleId: role.id,
},
});
});
} else {
const workspaceMember = currentWorkspaceMembers.find(
(member) => member.id === selectedWorkspaceMember.id,
);
if (!workspaceMember) {
throw new Error('Workspace member not found');
}
updateWorkspaceMemberRoleDraftState({
workspaceMember: {
id: workspaceMember.id,
name: workspaceMember.name,
colorScheme: '',
userEmail: '',
},
});
}
handleModalClose();
};
@ -190,11 +212,11 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
/>
</StyledSearchContainer>
<StyledTable>
<RoleAssignmentTableHeader />
<SettingsRoleAssignmentTableHeader />
<StyledTableRows>
{filteredWorkspaceMembers.length > 0 ? (
filteredWorkspaceMembers.map((workspaceMember) => (
<RoleAssignmentTableRow
<SettingsRoleAssignmentTableRow
key={workspaceMember.id}
workspaceMember={workspaceMember}
/>
@ -233,7 +255,7 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
</>
}
dropdownComponents={
<RoleAssignmentWorkspaceMemberPickerDropdown
<SettingsRoleAssignmentWorkspaceMemberPickerDropdown
excludedWorkspaceMemberIds={[
...assignedWorkspaceMemberIds,
currentWorkspaceMember?.id,
@ -246,7 +268,7 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
</Section>
{confirmationModalIsOpen && selectedWorkspaceMember && (
<RoleAssignmentConfirmationModal
<SettingsRoleAssignmentConfirmationModal
selectedWorkspaceMember={selectedWorkspaceMember}
isOpen={true}
onClose={handleModalClose}

View File

@ -1,23 +1,23 @@
import { RoleAssignmentConfirmationModalSubtitle } from '@/settings/roles/role-assignment/components/RoleAssignmentConfirmationModalSubtitle';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { SettingsRoleAssignmentConfirmationModalSubtitle } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModalSubtitle';
import { SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
type RoleAssignmentConfirmationModalProps = {
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
type SettingsRoleAssignmentConfirmationModalProps = {
selectedWorkspaceMember: SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onRoleClick: (roleId: string) => void;
};
export const RoleAssignmentConfirmationModal = ({
export const SettingsRoleAssignmentConfirmationModal = ({
selectedWorkspaceMember,
isOpen,
onClose,
onConfirm,
onRoleClick,
}: RoleAssignmentConfirmationModalProps) => {
}: SettingsRoleAssignmentConfirmationModalProps) => {
const workspaceMemberName = selectedWorkspaceMember.name;
const title = t`Assign ${workspaceMemberName}?`;
@ -28,7 +28,7 @@ export const RoleAssignmentConfirmationModal = ({
setIsOpen={onClose}
title={title}
subtitle={
<RoleAssignmentConfirmationModalSubtitle
<SettingsRoleAssignmentConfirmationModalSubtitle
selectedWorkspaceMember={selectedWorkspaceMember}
onRoleClick={onRoleClick}
/>

View File

@ -1,5 +1,5 @@
import { SettingsCard } from '@/settings/components/SettingsCard';
import { RoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/RoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Avatar } from 'twenty-ui';
@ -8,15 +8,15 @@ const StyledSettingsCardContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(6)};
`;
type RoleAssignmentConfirmationModalSubtitleProps = {
selectedWorkspaceMember: RoleAssignmentConfirmationModalSelectedWorkspaceMember;
type SettingsRoleAssignmentConfirmationModalSubtitleProps = {
selectedWorkspaceMember: SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember;
onRoleClick: (roleId: string) => void;
};
export const RoleAssignmentConfirmationModalSubtitle = ({
export const SettingsRoleAssignmentConfirmationModalSubtitle = ({
selectedWorkspaceMember,
onRoleClick,
}: RoleAssignmentConfirmationModalSubtitleProps) => {
}: SettingsRoleAssignmentConfirmationModalSubtitleProps) => {
const workspaceMemberName = selectedWorkspaceMember.name;
return (

View File

@ -2,7 +2,7 @@ import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { t } from '@lingui/core/macro';
export const RoleAssignmentTableHeader = () => (
export const SettingsRoleAssignmentTableHeader = () => (
<TableRow gridAutoColumns="2fr 4fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>

View File

@ -28,13 +28,13 @@ const StyledTableCell = styled(TableCell)`
overflow: hidden;
`;
type RoleAssignmentTableRowProps = {
type SettingsRoleAssignmentTableRowProps = {
workspaceMember: WorkspaceMember;
};
export const RoleAssignmentTableRow = ({
export const SettingsRoleAssignmentTableRow = ({
workspaceMember,
}: RoleAssignmentTableRowProps) => {
}: SettingsRoleAssignmentTableRowProps) => {
return (
<TableRow gridAutoColumns="2fr 4fr">
<StyledTableCell>

View File

@ -1,6 +1,6 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { RoleAssignmentWorkspaceMemberPickerDropdownContent } from '@/settings/roles/role-assignment/components/RoleAssignmentWorkspaceMemberPickerDropdownContent';
import { SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
@ -9,15 +9,15 @@ import { useLingui } from '@lingui/react/macro';
import { ChangeEvent, useState } from 'react';
import { SearchRecord } from '~/generated-metadata/graphql';
type RoleAssignmentWorkspaceMemberPickerDropdownProps = {
type SettingsRoleAssignmentWorkspaceMemberPickerDropdownProps = {
excludedWorkspaceMemberIds: string[];
onSelect: (workspaceMemberSearchRecord: SearchRecord) => void;
};
export const RoleAssignmentWorkspaceMemberPickerDropdown = ({
export const SettingsRoleAssignmentWorkspaceMemberPickerDropdown = ({
excludedWorkspaceMemberIds,
onSelect,
}: RoleAssignmentWorkspaceMemberPickerDropdownProps) => {
}: SettingsRoleAssignmentWorkspaceMemberPickerDropdownProps) => {
const [searchFilter, setSearchFilter] = useState('');
const { loading, searchRecords: workspaceMembers } =
@ -46,7 +46,7 @@ export const RoleAssignmentWorkspaceMemberPickerDropdown = ({
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<RoleAssignmentWorkspaceMemberPickerDropdownContent
<SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent
loading={loading}
searchFilter={searchFilter}
filteredWorkspaceMembers={filteredWorkspaceMembers}

View File

@ -2,19 +2,19 @@ import { t } from '@lingui/core/macro';
import { MenuItem, MenuItemAvatar } from 'twenty-ui';
import { SearchRecord } from '~/generated-metadata/graphql';
type RoleAssignmentWorkspaceMemberPickerDropdownContentProps = {
type SettingsRoleAssignmentWorkspaceMemberPickerDropdownContentProps = {
loading: boolean;
searchFilter: string;
filteredWorkspaceMembers: SearchRecord[];
onSelect: (workspaceMemberSearchRecord: SearchRecord) => void;
};
export const RoleAssignmentWorkspaceMemberPickerDropdownContent = ({
export const SettingsRoleAssignmentWorkspaceMemberPickerDropdownContent = ({
loading,
searchFilter,
filteredWorkspaceMembers,
onSelect,
}: RoleAssignmentWorkspaceMemberPickerDropdownContentProps) => {
}: SettingsRoleAssignmentWorkspaceMemberPickerDropdownContentProps) => {
if (loading) {
return null;
}

View File

@ -0,0 +1,46 @@
import { SettingsRoleAssignment } from '@/settings/roles/role-assignment/components/SettingsRoleAssignment';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { PENDING_ROLE_ID } from '~/pages/settings/roles/SettingsRoleCreate';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
const SettingsRoleAssignmentWrapper = (
args: React.ComponentProps<typeof SettingsRoleAssignment>,
) => {
const setDraftRole = useSetRecoilState(
settingsDraftRoleFamilyState(args.roleId),
);
const role = getRolesMock().find((role) => role.id === args.roleId);
if (isDefined(role)) {
setDraftRole(role);
}
return <SettingsRoleAssignment roleId={args.roleId} />;
};
const meta: Meta<typeof SettingsRoleAssignmentWrapper> = {
title: 'Modules/Settings/Roles/RoleAssignment/SettingsRoleAssignment',
component: SettingsRoleAssignmentWrapper,
decorators: [RouterDecorator, ComponentDecorator, I18nFrontDecorator],
};
export default meta;
type Story = StoryObj<typeof SettingsRoleAssignmentWrapper>;
export const Default: Story = {
args: {
roleId: '1',
},
};
export const PendingRole: Story = {
args: {
roleId: PENDING_ROLE_ID,
},
};

View File

@ -1,6 +0,0 @@
export type RoleAssignmentConfirmationModalSelectedWorkspaceMember = {
id: string;
name: string;
role?: { id: string; label: string };
avatarUrl?: string | null;
};

View File

@ -0,0 +1,8 @@
export type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember = {
id: string;
name: string;
role?: { id: string; label: string };
avatarUrl?: string | null;
colorScheme?: string;
userEmail?: string;
};

View File

@ -1,175 +0,0 @@
import { RolePermissionsObjectsTableHeader } from '@/settings/roles/role-permissions/components/RolePermissionsObjectsTableHeader';
import { RolePermissionsSettingsTableHeader } from '@/settings/roles/role-permissions/components/RolePermissionsSettingsTableHeader';
import { RolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/components/RolePermissionsSettingsTableRow';
import { RolePermissionsObjectPermission } from '@/settings/roles/types/RolePermissionsObjectPermission';
import { RolePermissionsSettingPermission } from '@/settings/roles/types/RolePermissionsSettingPermission';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import {
H2Title,
IconCode,
IconEye,
IconHierarchy,
IconKey,
IconLockOpen,
IconPencil,
IconServer,
IconSettings,
IconTrash,
IconTrashX,
IconUsers,
Section,
} from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
import { SettingPermissionType } from '~/generated/graphql';
import { RolePermissionsObjectsTableRow } from './RolePermissionsObjectsTableRow';
const StyledRolePermissionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(8)};
`;
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
type RolePermissionsProps = {
role: Pick<
Role,
| 'id'
| 'canUpdateAllSettings'
| 'canReadAllObjectRecords'
| 'canUpdateAllObjectRecords'
| 'canSoftDeleteAllObjectRecords'
| 'canDestroyAllObjectRecords'
>;
};
export const RolePermissions = ({ role }: RolePermissionsProps) => {
const objectPermissionsConfig: RolePermissionsObjectPermission[] = [
{
key: 'seeRecords',
label: 'See Records on All Objects',
Icon: IconEye,
value: role.canReadAllObjectRecords,
},
{
key: 'editRecords',
label: 'Edit Records on All Objects',
Icon: IconPencil,
value: role.canUpdateAllObjectRecords,
},
{
key: 'deleteRecords',
label: 'Delete Records on All Objects',
Icon: IconTrash,
value: role.canSoftDeleteAllObjectRecords,
},
{
key: 'destroyRecords',
label: 'Destroy Records on All Objects',
Icon: IconTrashX,
value: role.canDestroyAllObjectRecords,
},
];
const settingsPermissionsConfig: RolePermissionsSettingPermission[] = [
{
key: SettingPermissionType.API_KEYS_AND_WEBHOOKS,
name: 'API Keys & Webhooks',
description: 'Manage API keys and webhooks',
value: role.canUpdateAllSettings,
Icon: IconCode,
},
{
key: SettingPermissionType.WORKSPACE,
name: 'Workspace',
description: 'Set global workspace preferences',
value: role.canUpdateAllSettings,
Icon: IconSettings,
},
{
key: SettingPermissionType.WORKSPACE_MEMBERS,
name: 'Users',
description: 'Add or remove users',
value: role.canUpdateAllSettings,
Icon: IconUsers,
},
{
key: SettingPermissionType.ROLES,
name: 'Roles',
description: 'Define user roles and access levels',
value: role.canUpdateAllSettings,
Icon: IconLockOpen,
},
{
key: SettingPermissionType.DATA_MODEL,
name: 'Data Model',
description: 'Edit CRM data structure and fields',
value: role.canUpdateAllSettings,
Icon: IconHierarchy,
},
{
key: SettingPermissionType.ADMIN_PANEL,
name: 'Admin Panel',
description: 'Admin settings and system tools',
value: role.canUpdateAllSettings,
Icon: IconServer,
},
{
key: SettingPermissionType.SECURITY,
name: 'Security',
description: 'Manage security policies',
value: role.canUpdateAllSettings,
Icon: IconKey,
},
];
return (
<StyledRolePermissionsContainer>
<Section>
<H2Title
title={t`Objects`}
description={t`Ability to interact with each object`}
/>
<StyledTable>
<RolePermissionsObjectsTableHeader
allPermissions={objectPermissionsConfig.every(
(permission) => permission.value,
)}
/>
<StyledTableRows>
{objectPermissionsConfig.map((permission) => (
<RolePermissionsObjectsTableRow
key={permission.key}
permission={permission}
/>
))}
</StyledTableRows>
</StyledTable>
</Section>
<Section>
<H2Title title={t`Settings`} description={t`Settings permissions`} />
<StyledTable>
<RolePermissionsSettingsTableHeader
allPermissions={role.canUpdateAllSettings}
/>
<StyledTableRows>
{settingsPermissionsConfig.map((permission) => (
<RolePermissionsSettingsTableRow
key={permission.key}
permission={permission}
/>
))}
</StyledTableRows>
</StyledTable>
</Section>
</StyledRolePermissionsContainer>
);
};

View File

@ -1,35 +0,0 @@
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 StyledNameHeader = styled(TableHeader)`
flex: 1;
`;
const StyledActionsHeader = styled(TableHeader)`
align-items: center;
display: flex;
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(4)};
`;
type RolePermissionsObjectsTableHeaderProps = {
allPermissions: boolean;
};
export const RolePermissionsObjectsTableHeader = ({
allPermissions,
}: RolePermissionsObjectsTableHeaderProps) => (
<TableRow>
<StyledNameHeader>{t`Name`}</StyledNameHeader>
<StyledActionsHeader aria-label={t`Actions`}>
<Checkbox
checked={allPermissions}
indeterminate={!allPermissions}
disabled
/>
</StyledActionsHeader>
</TableRow>
);

View File

@ -0,0 +1,202 @@
import { SettingsRolePermissionsObjectsTableHeader } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableHeader';
import { SettingsRolePermissionsObjectsTableRow } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsObjectsTableRow';
import { SettingsRolePermissionsSettingsTableHeader } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableHeader';
import { SettingsRolePermissionsSettingsTableRow } from '@/settings/roles/role-permissions/components/SettingsRolePermissionsSettingsTableRow';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsRolePermissionsObjectPermission } from '@/settings/roles/types/SettingsRolePermissionsObjectPermission';
import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import {
H2Title,
IconCode,
IconEye,
IconHierarchy,
IconKey,
IconLockOpen,
IconPencil,
IconServer,
IconSettings,
IconTrash,
IconTrashX,
IconUsers,
Section,
} from 'twenty-ui';
import { SettingPermissionType } from '~/generated-metadata/graphql';
const StyledRolePermissionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(8)};
`;
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
type SettingsRolePermissionsProps = {
roleId: string;
isEditable: boolean;
};
export const SettingsRolePermissions = ({
roleId,
isEditable,
}: SettingsRolePermissionsProps) => {
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const objectPermissionsConfig: SettingsRolePermissionsObjectPermission[] = [
{
key: 'seeRecords',
label: t`See Records on All Objects`,
Icon: IconEye,
value: settingsDraftRole.canReadAllObjectRecords,
setValue: (value: boolean) => {
setSettingsDraftRole({
...settingsDraftRole,
canReadAllObjectRecords: value,
});
},
},
{
key: 'editRecords',
label: t`Edit Records on All Objects`,
Icon: IconPencil,
value: settingsDraftRole.canUpdateAllObjectRecords,
setValue: (value: boolean) => {
setSettingsDraftRole({
...settingsDraftRole,
canUpdateAllObjectRecords: value,
});
},
},
{
key: 'deleteRecords',
label: t`Delete Records on All Objects`,
Icon: IconTrash,
value: settingsDraftRole.canSoftDeleteAllObjectRecords,
setValue: (value: boolean) => {
setSettingsDraftRole({
...settingsDraftRole,
canSoftDeleteAllObjectRecords: value,
});
},
},
{
key: 'destroyRecords',
label: t`Destroy Records on All Objects`,
Icon: IconTrashX,
value: settingsDraftRole.canDestroyAllObjectRecords,
setValue: (value: boolean) => {
setSettingsDraftRole({
...settingsDraftRole,
canDestroyAllObjectRecords: value,
});
},
},
];
const settingsPermissionsConfig: SettingsRolePermissionsSettingPermission[] =
[
{
key: SettingPermissionType.API_KEYS_AND_WEBHOOKS,
name: t`API Keys & Webhooks`,
description: t`Manage API keys and webhooks`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconCode,
},
{
key: SettingPermissionType.WORKSPACE,
name: t`Workspace`,
description: t`Set global workspace preferences`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconSettings,
},
{
key: SettingPermissionType.WORKSPACE_MEMBERS,
name: t`Users`,
description: t`Add or remove users`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconUsers,
},
{
key: SettingPermissionType.ROLES,
name: t`Roles`,
description: t`Define user roles and access levels`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconLockOpen,
},
{
key: SettingPermissionType.DATA_MODEL,
name: t`Data Model`,
description: t`Edit CRM data structure and fields`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconHierarchy,
},
{
key: SettingPermissionType.ADMIN_PANEL,
name: t`Admin Panel`,
description: t`Admin settings and system tools`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconServer,
},
{
key: SettingPermissionType.SECURITY,
name: t`Security`,
description: t`Manage security policies`,
value: settingsDraftRole.canUpdateAllSettings,
Icon: IconKey,
},
];
return (
<StyledRolePermissionsContainer>
<Section>
<H2Title
title={t`Objects`}
description={t`Ability to interact with each object`}
/>
<StyledTable>
<SettingsRolePermissionsObjectsTableHeader
roleId={roleId}
objectPermissionsConfig={objectPermissionsConfig}
isEditable={isEditable}
/>
<StyledTableRows>
{objectPermissionsConfig.map((permission) => (
<SettingsRolePermissionsObjectsTableRow
key={permission.key}
permission={permission}
isEditable={isEditable}
/>
))}
</StyledTableRows>
</StyledTable>
</Section>
<Section>
<H2Title title={t`Settings`} description={t`Settings permissions`} />
<StyledTable>
<SettingsRolePermissionsSettingsTableHeader
allPermissions={settingsDraftRole.canUpdateAllSettings}
/>
<StyledTableRows>
{settingsPermissionsConfig.map((permission) => (
<SettingsRolePermissionsSettingsTableRow
key={permission.key}
permission={permission}
/>
))}
</StyledTableRows>
</StyledTable>
</Section>
</StyledRolePermissionsContainer>
);
};

View File

@ -0,0 +1,68 @@
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsRolePermissionsObjectPermission } from '@/settings/roles/types/SettingsRolePermissionsObjectPermission';
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 { useRecoilState } from 'recoil';
import { Checkbox } from 'twenty-ui';
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)};
`;
type SettingsRolePermissionsObjectsTableHeaderProps = {
roleId: string;
objectPermissionsConfig: SettingsRolePermissionsObjectPermission[];
isEditable: boolean;
};
export const SettingsRolePermissionsObjectsTableHeader = ({
roleId,
objectPermissionsConfig,
isEditable,
}: SettingsRolePermissionsObjectsTableHeaderProps) => {
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const allPermissionsEnabled = objectPermissionsConfig.every(
(permission) => permission.value,
);
const somePermissionsEnabled = objectPermissionsConfig.some(
(permission) => permission.value,
);
return (
<TableRow>
<StyledNameHeader>{t`Name`}</StyledNameHeader>
<StyledActionsHeader aria-label={t`Actions`}>
<Checkbox
checked={allPermissionsEnabled}
indeterminate={somePermissionsEnabled && !allPermissionsEnabled}
disabled={!isEditable}
aria-label={t`Toggle all object permissions`}
onChange={() => {
const newValue = !allPermissionsEnabled;
setSettingsDraftRole({
...settingsDraftRole,
canReadAllObjectRecords: newValue,
canUpdateAllObjectRecords: newValue,
canSoftDeleteAllObjectRecords: newValue,
canDestroyAllObjectRecords: newValue,
});
}}
/>
</StyledActionsHeader>
</TableRow>
);
};

View File

@ -1,6 +1,7 @@
import { RolePermissionsObjectPermission } from '@/settings/roles/types/RolePermissionsObjectPermission';
import { SettingsRolePermissionsObjectPermission } from '@/settings/roles/types/SettingsRolePermissionsObjectPermission';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Checkbox } from 'twenty-ui';
@ -46,25 +47,33 @@ const StyledTableRow = styled(TableRow)`
display: flex;
`;
type RolePermissionsObjectsTableRowProps = {
permission: RolePermissionsObjectPermission;
type SettingsRolePermissionsObjectsTableRowProps = {
permission: SettingsRolePermissionsObjectPermission;
isEditable: boolean;
};
export const RolePermissionsObjectsTableRow = ({
export const SettingsRolePermissionsObjectsTableRow = ({
permission,
}: RolePermissionsObjectsTableRowProps) => {
isEditable,
}: SettingsRolePermissionsObjectsTableRowProps) => {
const theme = useTheme();
return (
<StyledTableRow key={permission.key}>
<StyledTableRow>
<StyledPermissionCell>
<StyledIconWrapper>
<StyledIcon>
<permission.Icon size={14} />
<permission.Icon size={theme.icon.size.sm} />
</StyledIcon>
</StyledIconWrapper>
<StyledLabel>{permission.label}</StyledLabel>
</StyledPermissionCell>
<StyledCheckboxCell>
<Checkbox checked={permission.value} disabled />
<Checkbox
checked={permission.value}
onChange={() => permission.setValue(!permission.value)}
disabled={!isEditable}
/>
</StyledCheckboxCell>
</StyledTableRow>
);

View File

@ -6,7 +6,10 @@ import { Checkbox } from 'twenty-ui';
const StyledNameHeader = styled(TableHeader)`
flex: 1;
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledTypeHeader = styled(TableHeader)`
flex: 1;
`;
const StyledActionsHeader = styled(TableHeader)`
@ -16,22 +19,24 @@ const StyledActionsHeader = styled(TableHeader)`
padding-right: ${({ theme }) => theme.spacing(4)};
`;
const StyledTypeHeader = styled(TableHeader)`
flex: 1;
`;
type RolePermissionsSettingsTableHeaderProps = {
type SettingsRolePermissionsSettingsTableHeaderProps = {
allPermissions: boolean;
onToggleAll?: () => void;
};
export const RolePermissionsSettingsTableHeader = ({
export const SettingsRolePermissionsSettingsTableHeader = ({
allPermissions,
}: RolePermissionsSettingsTableHeaderProps) => (
onToggleAll,
}: SettingsRolePermissionsSettingsTableHeaderProps) => (
<TableRow gridAutoColumns="3fr 4fr 24px">
<StyledNameHeader>{t`Name`}</StyledNameHeader>
<StyledTypeHeader>{t`Description`}</StyledTypeHeader>
<StyledActionsHeader aria-label={t`Actions`}>
<Checkbox checked={allPermissions} disabled />
<Checkbox
checked={allPermissions}
disabled={!onToggleAll}
onChange={onToggleAll}
/>
</StyledActionsHeader>
</TableRow>
);

View File

@ -1,4 +1,4 @@
import { RolePermissionsSettingPermission } from '@/settings/roles/types/RolePermissionsSettingPermission';
import { SettingsRolePermissionsSettingPermission } from '@/settings/roles/types/SettingsRolePermissionsSettingPermission';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
@ -33,13 +33,13 @@ const StyledIconContainer = styled.div`
justify-content: center;
`;
type RolePermissionsSettingsTableRowProps = {
permission: RolePermissionsSettingPermission;
type SettingsRolePermissionsSettingsTableRowProps = {
permission: SettingsRolePermissionsSettingPermission;
};
export const RolePermissionsSettingsTableRow = ({
export const SettingsRolePermissionsSettingsTableRow = ({
permission,
}: RolePermissionsSettingsTableRowProps) => {
}: SettingsRolePermissionsSettingsTableRowProps) => {
const theme = useTheme();
return (
@ -47,7 +47,7 @@ export const RolePermissionsSettingsTableRow = ({
<StyledPermissionCell>
<StyledIconContainer>
<permission.Icon
size={16}
size={theme.icon.size.md}
color={theme.font.color.primary}
stroke={theme.icon.stroke.sm}
/>

View File

@ -0,0 +1,59 @@
import { SettingsRolePermissions } from '@/settings/roles/role-permissions/components/SettingsRolePermissions';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { PENDING_ROLE_ID } from '~/pages/settings/roles/SettingsRoleCreate';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
const SettingsRolePermissionsWrapper = (
args: React.ComponentProps<typeof SettingsRolePermissions>,
) => {
const setDraftRole = useSetRecoilState(
settingsDraftRoleFamilyState(args.roleId),
);
const role = getRolesMock().find((role) => role.id === args.roleId);
if (isDefined(role)) {
setDraftRole(role);
}
return (
<SettingsRolePermissions
roleId={args.roleId}
isEditable={args.isEditable}
/>
);
};
const meta: Meta<typeof SettingsRolePermissionsWrapper> = {
title: 'Modules/Settings/Roles/RolePermissions/SettingsRolePermissions',
component: SettingsRolePermissionsWrapper,
decorators: [RouterDecorator, ComponentDecorator, I18nFrontDecorator],
};
export default meta;
type Story = StoryObj<typeof SettingsRolePermissionsWrapper>;
export const Default: Story = {
args: {
roleId: '1',
isEditable: true,
},
};
export const ReadOnly: Story = {
args: {
roleId: '1',
isEditable: false,
},
};
export const PendingRole: Story = {
args: {
roleId: PENDING_ROLE_ID,
},
};

View File

@ -1,47 +0,0 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { Section } from 'twenty-ui';
import { Role } from '~/generated-metadata/graphql';
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
`;
type RoleSettingsProps = {
role: Pick<Role, 'id' | 'label' | 'description' | 'icon'>;
};
export const RoleSettings = ({ role }: RoleSettingsProps) => {
return (
<Section>
<StyledInputsContainer>
<StyledInputContainer>
<IconPicker
disabled={true}
selectedIconKey={role.icon ?? 'IconUser'}
onChange={() => {}}
/>
</StyledInputContainer>
<TextInput value={role.label} disabled fullWidth />
</StyledInputsContainer>
<TextArea
minRows={4}
placeholder={t`Write a description`}
value={role.description || ''}
disabled
/>
</Section>
);
};

View File

@ -0,0 +1,79 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { useRecoilState } from 'recoil';
import { Section } from 'twenty-ui';
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
`;
type SettingsRoleSettingsProps = {
roleId: string;
isEditable: boolean;
};
export const SettingsRoleSettings = ({
roleId,
isEditable,
}: SettingsRoleSettingsProps) => {
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
return (
<Section>
<StyledInputsContainer>
<StyledInputContainer>
<IconPicker
selectedIconKey={settingsDraftRole.icon ?? 'IconUser'}
dropdownId="role-settings-icon-picker"
onChange={({ iconKey }: { iconKey: string }) => {
setSettingsDraftRole({
...settingsDraftRole,
icon: iconKey,
});
}}
disabled={!isEditable}
/>
</StyledInputContainer>
<TextInput
value={settingsDraftRole.label}
fullWidth
onChange={(value: string) => {
setSettingsDraftRole({
...settingsDraftRole,
label: value,
});
}}
placeholder={t`Role name`}
disabled={!isEditable}
/>
</StyledInputsContainer>
<TextArea
minRows={4}
placeholder={t`Write a description`}
value={settingsDraftRole.description || ''}
onChange={(value: string) => {
setSettingsDraftRole({
...settingsDraftRole,
description: value,
});
}}
disabled={!isEditable}
/>
</Section>
);
};

View File

@ -0,0 +1,56 @@
import { SettingsRoleSettings } from '@/settings/roles/role-settings/components/SettingsRoleSettings';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { PENDING_ROLE_ID } from '~/pages/settings/roles/SettingsRoleCreate';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getRolesMock } from '~/testing/mock-data/roles';
const SettingsRoleSettingsWrapper = (
args: React.ComponentProps<typeof SettingsRoleSettings>,
) => {
const setDraftRole = useSetRecoilState(
settingsDraftRoleFamilyState(args.roleId),
);
const role = getRolesMock().find((role) => role.id === args.roleId);
if (isDefined(role)) {
setDraftRole(role);
}
return (
<SettingsRoleSettings roleId={args.roleId} isEditable={args.isEditable} />
);
};
const meta: Meta<typeof SettingsRoleSettingsWrapper> = {
title: 'Modules/Settings/Roles/RoleSettings/SettingsRoleSettings',
component: SettingsRoleSettingsWrapper,
decorators: [RouterDecorator, ComponentDecorator, I18nFrontDecorator],
};
export default meta;
type Story = StoryObj<typeof SettingsRoleSettingsWrapper>;
export const Default: Story = {
args: {
roleId: '1',
isEditable: true,
},
};
export const ReadOnly: Story = {
args: {
roleId: '1',
isEditable: false,
},
};
export const PendingRole: Story = {
args: {
roleId: PENDING_ROLE_ID,
},
};

View File

@ -0,0 +1,198 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole';
import { SettingsRoleAssignment } from '@/settings/roles/role-assignment/components/SettingsRoleAssignment';
import { SettingsRolePermissions } from '@/settings/roles/role-permissions/components/SettingsRolePermissions';
import { SettingsRoleSettings } from '@/settings/roles/role-settings/components/SettingsRoleSettings';
import { SettingsRoleLabelContainer } from '@/settings/roles/role/components/SettingsRoleLabelContainer';
import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/SettingsRoleDetailTabs';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Button, IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui';
import { v4 } from 'uuid';
import {
FeatureFlagKey,
useCreateOneRoleMutation,
useUpdateOneRoleMutation,
} from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsRoleProps = {
roleId: string;
isCreateMode: boolean;
};
export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
const isPermissionsV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsPermissionsV2Enabled,
);
const navigateSettings = useNavigateSettings();
const [createRole] = useCreateOneRoleMutation();
const [updateRole] = useUpdateOneRoleMutation();
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const settingsPersistedRole = useRecoilValue(
settingsPersistedRoleFamilyState(roleId),
);
const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId);
const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState);
if (!isDefined(settingsRolesIsLoading)) {
return <></>;
}
const isRoleEditable = isPermissionsV2Enabled && settingsDraftRole.isEditable;
const tabs = [
{
id: SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.ASSIGNMENT,
title: t`Assignment`,
Icon: IconUserPlus,
},
{
id: SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.PERMISSIONS,
title: t`Permissions`,
Icon: IconLockOpen,
},
{
id: SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS,
title: t`Settings`,
Icon: IconSettings,
},
];
const isDirty = !isDeeplyEqual(settingsDraftRole, settingsPersistedRole);
const handleSave = () => {
if (isCreateMode) {
const roleId = v4();
createRole({
variables: {
createRoleInput: {
id: roleId,
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canReadAllObjectRecords: settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
},
},
onCompleted: async (data) => {
await addWorkspaceMembersToRole({
roleId: data.createOneRole.id,
workspaceMemberIds: settingsDraftRole.workspaceMembers.map(
(member) => member.id,
),
});
navigateSettings(SettingsPath.RoleDetail, {
roleId: data.createOneRole.id,
});
},
});
} else {
updateRole({
variables: {
updateRoleInput: {
id: roleId,
update: {
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canReadAllObjectRecords:
settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
},
},
},
});
}
};
return (
<SubMenuTopBarContainer
title={<SettingsRoleLabelContainer roleId={roleId} />}
links={[
{
children: 'Workspace',
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: 'Roles',
href: getSettingsPath(SettingsPath.Roles),
},
{
children: settingsDraftRole.label,
},
]}
actionButton={
isDirty && (
<Button
title={isCreateMode ? t`Create` : t`Save`}
variant="primary"
size="small"
accent="blue"
onClick={handleSave}
disabled={!isRoleEditable}
/>
)
}
>
<SettingsPageContainer>
<TabList
tabs={tabs}
className="tab-list"
componentInstanceId={SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID}
/>
{activeTabId === SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.ASSIGNMENT && (
<SettingsRoleAssignment roleId={roleId} isCreateMode={isCreateMode} />
)}
{activeTabId === SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.PERMISSIONS && (
<SettingsRolePermissions
roleId={roleId}
isEditable={isRoleEditable}
/>
)}
{activeTabId === SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS && (
<SettingsRoleSettings roleId={roleId} isEditable={isRoleEditable} />
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,45 @@
import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/SettingsRoleDetailTabs';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect, useState } from 'react';
import { useSetRecoilState } from 'recoil';
export const SettingsRoleCreateEffect = ({ roleId }: { roleId: string }) => {
const setSettingsDraftRole = useSetRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState,
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (isInitialized) {
return;
}
setActiveTabId(SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.PERMISSIONS);
const newRole = {
id: roleId,
label: '',
description: '',
icon: 'IconUser',
canUpdateAllSettings: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
isEditable: true,
workspaceMembers: [],
};
setSettingsDraftRole(newRole);
setIsInitialized(true);
}, [isInitialized, roleId, setActiveTabId, setSettingsDraftRole]);
return null;
};

View File

@ -0,0 +1,40 @@
import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/SettingsRoleDetailTabs';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type SettingsRoleEditEffectProps = {
roleId: string;
};
export const SettingsRoleEditEffect = ({
roleId,
}: SettingsRoleEditEffectProps) => {
const [isInitialized, setIsInitialized] = useState(false);
const role = useRecoilValue(settingsPersistedRoleFamilyState(roleId));
const setDraftRole = useSetRecoilState(settingsDraftRoleFamilyState(roleId));
const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState,
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
useEffect(() => {
if (isInitialized) {
return;
}
setActiveTabId(SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.ASSIGNMENT);
if (isDefined(role)) {
setDraftRole(role);
setIsInitialized(true);
}
}, [isInitialized, role, setActiveTabId, setDraftRole]);
return <></>;
};

View File

@ -0,0 +1,50 @@
import { useRecoilState } from 'recoil';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import styled from '@emotion/styled';
type SettingsRoleLabelContainerProps = {
roleId: string;
};
const ROLE_LABEL_EDIT_HOTKEY_SCOPE = 'role-label-edit';
const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.lg};
width: fit-content;
max-width: 420px;
& > input:disabled {
color: ${({ theme }) => theme.font.color.primary};
}
`;
export const SettingsRoleLabelContainer = ({
roleId,
}: SettingsRoleLabelContainerProps) => {
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const handleChange = (newValue: string) => {
setSettingsDraftRole({
...settingsDraftRole,
label: newValue,
});
};
return (
<StyledHeaderTitle>
<TitleInput
disabled={!settingsDraftRole.isEditable}
sizeVariant="md"
value={settingsDraftRole.label}
onChange={handleChange}
placeholder="Role name"
hotkeyScope={ROLE_LABEL_EDIT_HOTKEY_SCOPE}
/>
</StyledHeaderTitle>
);
};

View File

@ -0,0 +1,8 @@
export const SETTINGS_ROLE_DETAIL_TABS = {
COMPONENT_INSTANCE_ID: 'settings-role-detail-tabs',
TABS_IDS: {
ASSIGNMENT: 'assignment',
PERMISSIONS: 'permissions',
SETTINGS: 'settings',
},
} as const;

View File

@ -0,0 +1,14 @@
import { selector } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { settingsPersistedRoleFamilyState } from './settingsPersistedRoleFamilyState';
import { settingsRoleIdsState } from './settingsRoleIdsState';
export const settingsAllRolesSelector = selector({
key: 'settingsAllRolesSelector',
get: ({ get }) => {
const roleIds = get(settingsRoleIdsState);
return roleIds
.map((roleId) => get(settingsPersistedRoleFamilyState(roleId)))
.filter(isDefined);
},
});

View File

@ -0,0 +1,19 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { Role } from '~/generated/graphql';
export const settingsDraftRoleFamilyState = createFamilyState<Role, string>({
key: 'settingsDraftRoleFamilyState',
defaultValue: {
id: '',
label: '',
description: '',
icon: '',
canDestroyAllObjectRecords: false,
canReadAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canUpdateAllSettings: false,
isEditable: false,
workspaceMembers: [],
},
});

View File

@ -0,0 +1,10 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { Role } from '~/generated/graphql';
export const settingsPersistedRoleFamilyState = createFamilyState<
Role | undefined,
string
>({
key: 'settingsPersistedRoleFamilyState',
defaultValue: undefined,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const settingsRoleIdsState = atom<string[]>({
key: 'settingsRoleIdsState',
default: [],
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const settingsRolesIsLoadingState = atom<boolean>({
key: 'settingsRolesIsLoadingState',
default: true,
});

View File

@ -0,0 +1,12 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { Role } from '~/generated-metadata/graphql';
export const settingsValidateRoleFamilyState = createFamilyState<
Record<keyof Pick<Role, 'label'>, boolean>,
string
>({
key: 'settingsValidateRoleFamilyState',
defaultValue: {
label: false,
},
});

View File

@ -1,8 +1,9 @@
import { IconComponent } from 'twenty-ui';
export type RolePermissionsObjectPermission = {
export type SettingsRolePermissionsObjectPermission = {
key: string;
label: string;
value: boolean;
Icon: IconComponent;
setValue: (value: boolean) => void;
};

View File

@ -1,6 +1,6 @@
import { IconComponent } from 'twenty-ui';
export type RolePermissionsSettingPermission = {
export type SettingsRolePermissionsSettingPermission = {
key: string;
name: string;
description: string;

View File

@ -42,5 +42,6 @@ export enum SettingsPath {
AdminPanelOtherEnvVariables = 'admin-panel/other-env-variables',
Lab = 'lab',
Roles = 'roles',
RoleCreate = 'roles/create',
RoleDetail = 'roles/:roleId',
}

View File

@ -0,0 +1,15 @@
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
import { SettingsRole } from '@/settings/roles/role/components/SettingsRole';
import { SettingsRoleCreateEffect } from '@/settings/roles/role/components/SettingsRoleCreateEffect';
export const PENDING_ROLE_ID = 'pending-role-id';
export const SettingsRoleCreate = () => {
return (
<>
<SettingsRolesQueryEffect />
<SettingsRoleCreateEffect roleId={PENDING_ROLE_ID} />
<SettingsRole roleId={PENDING_ROLE_ID} isCreateMode={true} />
</>
);
};

View File

@ -1,119 +1,21 @@
import { t } from '@lingui/core/macro';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { H3Title, IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { RoleAssignment } from '@/settings/roles/role-assignment/components/RoleAssignment';
import { RolePermissions } from '@/settings/roles/role-permissions/components/RolePermissions';
import { RoleSettings } from '@/settings/roles/role-settings/components/RoleSettings';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetRolesQuery } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SETTINGS_ROLE_DETAIL_TABS = {
COMPONENT_INSTANCE_ID: 'settings-role-detail-tabs',
TABS_IDS: {
ASSIGNMENT: 'assignment',
PERMISSIONS: 'permissions',
SETTINGS: 'settings',
},
} as const;
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
import { SettingsRole } from '@/settings/roles/role/components/SettingsRole';
import { SettingsRoleEditEffect } from '@/settings/roles/role/components/SettingsRoleEditEffect';
import { Navigate, useParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
export const SettingsRoleEdit = () => {
const { roleId = '' } = useParams();
const navigateSettings = useNavigateSettings();
const { data: rolesData, loading: rolesLoading } = useGetRolesQuery({
fetchPolicy: 'network-only',
});
const { roleId } = useParams();
const role = rolesData?.getRoles.find((r) => r.id === roleId);
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
useEffect(() => {
if (!rolesLoading && !role) {
navigateSettings(SettingsPath.Roles);
}
}, [role, navigateSettings, rolesLoading]);
const tabs = [
{
id: SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.ASSIGNMENT,
title: t`Assignment`,
Icon: IconUserPlus,
hide: false,
},
{
id: SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.PERMISSIONS,
title: t`Permissions`,
Icon: IconLockOpen,
hide: false,
},
{
id: SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS,
title: t`Settings`,
Icon: IconSettings,
hide: false,
},
];
const renderActiveTabContent = () => {
if (!role) {
return null;
}
switch (activeTabId) {
case SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.ASSIGNMENT:
return <RoleAssignment role={role} />;
case SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.PERMISSIONS:
return <RolePermissions role={role} />;
case SETTINGS_ROLE_DETAIL_TABS.TABS_IDS.SETTINGS:
return <RoleSettings role={role} />;
default:
return null;
}
};
if (!isDefined(roleId)) {
return <Navigate to="/settings/roles" />;
}
return (
<SubMenuTopBarContainer
title={role && <H3Title title={role.label} />}
links={[
{
children: 'Workspace',
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: 'Roles',
href: getSettingsPath(SettingsPath.Roles),
},
{
children: role?.label,
},
]}
>
{!rolesLoading && role ? (
<SettingsPageContainer>
<TabList
tabs={tabs}
className="tab-list"
componentInstanceId={
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID
}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
) : (
<></>
)}
</SubMenuTopBarContainer>
<>
<SettingsRolesQueryEffect />
<SettingsRoleEditEffect roleId={roleId} />
<SettingsRole roleId={roleId} isCreateMode={false} />
</>
);
};

View File

@ -1,39 +1,11 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Roles } from '@/settings/roles/components/Roles';
import { RolesDefaultRole } from '@/settings/roles/components/RolesDefaultRole';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { H3Title } from 'twenty-ui';
import { useGetRolesQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsRolesContainer } from '@/settings/roles/components/SettingsRolesContainer';
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
export const SettingsRoles = () => {
const { t } = useLingui();
const { data: rolesData, loading: rolesLoading } = useGetRolesQuery({
fetchPolicy: 'network-only',
});
return (
<SubMenuTopBarContainer
title={rolesData && <H3Title title={t`Roles`} />}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: <Trans>Roles</Trans> },
]}
>
<SettingsPageContainer>
{!rolesLoading && rolesData && (
<>
<Roles roles={rolesData.getRoles ?? []} />
<RolesDefaultRole roles={rolesData.getRoles ?? []} />
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
<>
<SettingsRolesQueryEffect />
<SettingsRolesContainer />
</>
);
};

View File

@ -0,0 +1,27 @@
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsRoleCreate } from '../SettingsRoleCreate';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Roles/SettingsRoleCreate',
component: SettingsRoleCreate,
decorators: [PageDecorator],
args: {
routePath: '/settings/roles/create',
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsRoleCreate>;
export const Default: Story = {};

View File

@ -6,13 +6,18 @@ import {
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { getOperationName } from '@apollo/client/utilities';
import { graphql, HttpResponse } from 'msw';
import { SettingsRoles } from '../SettingsRoles';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Roles/SettingsRoles',
component: SettingsRoles,
decorators: [PageDecorator],
args: { routePath: '/settings/roles' },
args: {
routePath: '/settings/roles',
},
parameters: {
msw: graphqlMocks,
},
@ -23,3 +28,19 @@ export default meta;
export type Story = StoryObj<typeof SettingsRoles>;
export const Default: Story = {};
export const NoRoles: Story = {
parameters: {
msw: {
handlers: [
graphql.query(getOperationName(GET_ROLES) ?? '', () => {
return HttpResponse.json({
data: {
getRoles: [],
},
});
}),
],
},
},
};

View File

@ -1,9 +1,14 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateRoleInput {
@IsUUID()
@IsOptional()
@Field({ nullable: true })
id?: string;
@IsString()
@Field({ nullable: false })
label: string;

View File

@ -100,12 +100,10 @@ const StyledInput = styled.input<InputProps>`
& + label:before {
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
background: ${({ theme, indeterminate, isChecked, disabled }) =>
disabled && isChecked
? theme.adaptiveColors.blue3
: indeterminate || isChecked
? theme.color.blue
: 'transparent'};
background: ${({ theme, indeterminate, isChecked, disabled }) => {
if (!(indeterminate || isChecked)) return 'transparent';
return disabled ? theme.adaptiveColors.blue3 : theme.color.blue;
}};
border-color: ${({
theme,
indeterminate,