diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index b17a701e96..e6ff56414a 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3666,6 +3666,7 @@ type Mutation { verifyTwoFactorAuthenticationMethodForAuthenticatedUser(otp: String!): VerifyTwoFactorAuthenticationMethod! deleteUser: User! deleteUserFromWorkspace(workspaceMemberIdToDelete: String!): UserWorkspace! + updateWorkspaceMemberSettings(input: UpdateWorkspaceMemberSettingsInput!): Boolean! updateUserEmail(newEmail: String!, verifyEmailRedirectPath: String): Boolean! resendEmailVerificationToken(email: String!, origin: String!): ResendEmailVerificationToken! activateWorkspace(data: ActivateWorkspaceInput!): Workspace! @@ -4600,6 +4601,11 @@ input UpdateApplicationRegistrationVariablePayload { description: String } +input UpdateWorkspaceMemberSettingsInput { + workspaceMemberId: UUID! + update: JSON! +} + input ActivateWorkspaceInput { displayName: String } diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 2f9685f45b..309a2fe605 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -3121,6 +3121,7 @@ export interface Mutation { verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethod deleteUser: User deleteUserFromWorkspace: UserWorkspace + updateWorkspaceMemberSettings: Scalars['Boolean'] updateUserEmail: Scalars['Boolean'] resendEmailVerificationToken: ResendEmailVerificationToken activateWorkspace: Workspace @@ -6508,6 +6509,7 @@ export interface MutationGenqlSelection{ verifyTwoFactorAuthenticationMethodForAuthenticatedUser?: (VerifyTwoFactorAuthenticationMethodGenqlSelection & { __args: {otp: Scalars['String']} }) deleteUser?: UserGenqlSelection deleteUserFromWorkspace?: (UserWorkspaceGenqlSelection & { __args: {workspaceMemberIdToDelete: Scalars['String']} }) + updateWorkspaceMemberSettings?: { __args: {input: UpdateWorkspaceMemberSettingsInput} } updateUserEmail?: { __args: {newEmail: Scalars['String'], verifyEmailRedirectPath?: (Scalars['String'] | null)} } resendEmailVerificationToken?: (ResendEmailVerificationTokenGenqlSelection & { __args: {email: Scalars['String'], origin: Scalars['String']} }) activateWorkspace?: (WorkspaceGenqlSelection & { __args: {data: ActivateWorkspaceInput} }) @@ -6859,6 +6861,8 @@ export interface UpdateApplicationRegistrationVariableInput {id: Scalars['String export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),description?: (Scalars['String'] | null)} +export interface UpdateWorkspaceMemberSettingsInput {workspaceMemberId: Scalars['UUID'],update: Scalars['JSON']} + export interface ActivateWorkspaceInput {displayName?: (Scalars['String'] | null)} export interface UpdateWorkspaceInput {subdomain?: (Scalars['String'] | null),customDomain?: (Scalars['String'] | null),displayName?: (Scalars['String'] | null),logo?: (Scalars['String'] | null),inviteHash?: (Scalars['String'] | null),isPublicInviteLinkEnabled?: (Scalars['Boolean'] | null),allowImpersonation?: (Scalars['Boolean'] | null),isGoogleAuthEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthEnabled?: (Scalars['Boolean'] | null),isPasswordAuthEnabled?: (Scalars['Boolean'] | null),isGoogleAuthBypassEnabled?: (Scalars['Boolean'] | null),isMicrosoftAuthBypassEnabled?: (Scalars['Boolean'] | null),isPasswordAuthBypassEnabled?: (Scalars['Boolean'] | null),defaultRoleId?: (Scalars['UUID'] | null),isTwoFactorAuthenticationEnforced?: (Scalars['Boolean'] | null),trashRetentionDays?: (Scalars['Float'] | null),eventLogRetentionDays?: (Scalars['Float'] | null),fastModel?: (Scalars['String'] | null),smartModel?: (Scalars['String'] | null),aiAdditionalInstructions?: (Scalars['String'] | null),editableProfileFields?: (Scalars['String'][] | null),enabledAiModelIds?: (Scalars['String'][] | null),useRecommendedModels?: (Scalars['Boolean'] | null)} diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 541c6608d5..a158ba7586 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -89,9 +89,9 @@ export default { 380, 387, 418, - 499, - 504, - 505 + 500, + 505, + 506 ], "types": { "BillingProductDTO": { @@ -9068,6 +9068,15 @@ export default { ] } ], + "updateWorkspaceMemberSettings": [ + 6, + { + "input": [ + 488, + "UpdateWorkspaceMemberSettingsInput!" + ] + } + ], "updateUserEmail": [ 6, { @@ -9097,7 +9106,7 @@ export default { 66, { "data": [ - 488, + 489, "ActivateWorkspaceInput!" ] } @@ -9106,7 +9115,7 @@ export default { 66, { "data": [ - 489, + 490, "UpdateWorkspaceInput!" ] } @@ -9121,7 +9130,7 @@ export default { 232, { "input": [ - 490, + 491, "SetupOIDCSsoInput!" ] } @@ -9130,7 +9139,7 @@ export default { 232, { "input": [ - 491, + 492, "SetupSAMLSsoInput!" ] } @@ -9139,7 +9148,7 @@ export default { 228, { "input": [ - 492, + 493, "DeleteSsoInput!" ] } @@ -9148,7 +9157,7 @@ export default { 229, { "input": [ - 493, + 494, "EditSsoInput!" ] } @@ -9170,7 +9179,7 @@ export default { 323, { "input": [ - 494, + 495, "SendEmailInput!" ] } @@ -9196,7 +9205,7 @@ export default { "String!" ], "connectionParameters": [ - 496, + 497, "EmailAccountConnectionParameters!" ], "id": [ @@ -9208,7 +9217,7 @@ export default { 157, { "input": [ - 498, + 499, "UpdateLabPublicFeatureFlagInput!" ] } @@ -9286,7 +9295,7 @@ export default { 6, { "role": [ - 499, + 500, "AiModelRole!" ], "modelId": [ @@ -9491,7 +9500,7 @@ export default { 68, { "input": [ - 500, + 501, "CreateOneAppTokenInput!" ] } @@ -9527,7 +9536,7 @@ export default { 6, { "workspaceMigration": [ - 502, + 503, "WorkspaceMigrationInput!" ] } @@ -9601,7 +9610,7 @@ export default { "String!" ], "fileFolder": [ - 505, + 506, "FileFolder!" ], "filePath": [ @@ -11640,6 +11649,17 @@ export default { 1 ] }, + "UpdateWorkspaceMemberSettingsInput": { + "workspaceMemberId": [ + 3 + ], + "update": [ + 15 + ], + "__typename": [ + 1 + ] + }, "ActivateWorkspaceInput": { "displayName": [ 1 @@ -11804,7 +11824,7 @@ export default { 1 ], "files": [ - 495 + 496 ], "__typename": [ 1 @@ -11823,13 +11843,13 @@ export default { }, "EmailAccountConnectionParameters": { "IMAP": [ - 497 + 498 ], "SMTP": [ - 497 + 498 ], "CALDAV": [ - 497 + 498 ], "__typename": [ 1 @@ -11869,7 +11889,7 @@ export default { "AiModelRole": {}, "CreateOneAppTokenInput": { "appToken": [ - 501 + 502 ], "__typename": [ 1 @@ -11885,7 +11905,7 @@ export default { }, "WorkspaceMigrationInput": { "actions": [ - 503 + 504 ], "__typename": [ 1 @@ -11893,7 +11913,7 @@ export default { }, "WorkspaceMigrationDeleteActionInput": { "type": [ - 504 + 505 ], "metadataName": [ 355 @@ -11921,7 +11941,7 @@ export default { 260, { "input": [ - 507, + 508, "LogicFunctionLogsInput!" ] } diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d5bc9a198d..69f9a5558c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2789,6 +2789,7 @@ export type Mutation = { updateWorkspace: Workspace; updateWorkspaceFeatureFlag: Scalars['Boolean']; updateWorkspaceMemberRole: WorkspaceMember; + updateWorkspaceMemberSettings: Scalars['Boolean']; upgradeApplication: Scalars['Boolean']; uploadAIChatFile: FileWithSignedUrl; uploadAppTarball: ApplicationRegistration; @@ -3788,6 +3789,11 @@ export type MutationUpdateWorkspaceMemberRoleArgs = { }; +export type MutationUpdateWorkspaceMemberSettingsArgs = { + input: UpdateWorkspaceMemberSettingsInput; +}; + + export type MutationUpgradeApplicationArgs = { appRegistrationId: Scalars['String']; targetVersion: Scalars['String']; @@ -5828,6 +5834,11 @@ export type UpdateWorkspaceInput = { useRecommendedModels?: InputMaybe; }; +export type UpdateWorkspaceMemberSettingsInput = { + update: Scalars['JSON']; + workspaceMemberId: Scalars['UUID']; +}; + export type UpsertFieldPermissionsInput = { fieldPermissions: Array; roleId: Scalars['UUID']; @@ -7966,6 +7977,13 @@ export type UpdateUserEmailMutationVariables = Exact<{ export type UpdateUserEmailMutation = { __typename?: 'Mutation', updateUserEmail: boolean }; +export type UpdateWorkspaceMemberSettingsMutationVariables = Exact<{ + input: UpdateWorkspaceMemberSettingsInput; +}>; + + +export type UpdateWorkspaceMemberSettingsMutation = { __typename?: 'Mutation', updateWorkspaceMemberSettings: boolean }; + export type ApiKeyForRoleFragmentFragment = { __typename?: 'ApiKeyForRole', id: string, name: string, expiresAt: string, revokedAt?: string | null }; export type FieldPermissionFragmentFragment = { __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string }; @@ -8802,6 +8820,7 @@ export const EnterpriseSubscriptionStatusDocument = {"kind":"Document","definiti export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode; export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode; +export const UpdateWorkspaceMemberSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceMemberSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkspaceMemberSettingsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkspaceMemberSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const CreateOneRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"createRoleInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"createRoleInput"},"value":{"kind":"Variable","name":{"kind":"Name","value":"createRoleInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}}]} as unknown as DocumentNode; export const DeleteOneRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}}]}]}}]} as unknown as DocumentNode; export const UpdateOneRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOneRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"updateRoleInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateRoleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOneRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"updateRoleInput"},"value":{"kind":"Variable","name":{"kind":"Name","value":"updateRoleInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RoleFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RoleFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllSettings"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessAllTools"}},{"kind":"Field","name":{"kind":"Name","value":"isEditable"}},{"kind":"Field","name":{"kind":"Name","value":"canReadAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canUpdateAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canSoftDeleteAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canDestroyAllObjectRecords"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToUsers"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToAgents"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx index 11d121cecb..2c60c6b010 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx @@ -5,11 +5,13 @@ import { useCallback, useState } from 'react'; import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatus'; import { type CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; +import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly'; import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext'; import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems'; import { @@ -19,9 +21,9 @@ import { } from '@/object-record/record-field/ui/contexts/FieldContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; -import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; -import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly'; +import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { AvatarOrIcon, @@ -32,7 +34,6 @@ import { } from 'twenty-ui/components'; import { IconCalendarEvent } from 'twenty-ui/display'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; type CalendarEventDetailsProps = { @@ -70,7 +71,6 @@ const StyledTitle = styled.h2<{ canceled?: boolean }>` font-weight: ${themeCssVariables.font.weight.semiBold}; margin: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]}; - text-decoration: ${({ canceled }) => (canceled ? 'line-through' : 'none')}; `; @@ -149,12 +149,21 @@ export const CalendarEventDetails = ({ const objectPermissions = useObjectPermissionsForObject( objectMetadataItem.id, ); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const isRecordReadOnly = useIsRecordReadOnly({ recordId: calendarEvent.id, objectMetadataId: objectMetadataItem.id, }); const renderField = (fieldMetadataItem: FieldMetadataItem) => { + const fieldDefinition = formatFieldMetadataItemAsFieldDefinition({ + field: fieldMetadataItem, + objectMetadataItem, + showLabel: true, + labelWidth: 72, + }); + const isReadOnly = isRecordFieldReadOnly({ isRecordReadOnly, isSystemObject: objectMetadataItem.isSystem, @@ -164,6 +173,8 @@ export const CalendarEventDetails = ({ isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false, isCustom: fieldMetadataItem.isCustom ?? false, }, + fieldDefinition, + objectPermissionsByObjectMetadataId, }); return ( @@ -173,12 +184,7 @@ export const CalendarEventDetails = ({ value={{ recordId: calendarEvent.id, isLabelIdentifier: false, - fieldDefinition: formatFieldMetadataItemAsFieldDefinition({ - field: fieldMetadataItem, - objectMetadataItem, - showLabel: true, - labelWidth: 72, - }), + fieldDefinition, useUpdateRecord: useUpdateOneCalendarEventRecordMutation, maxWidth: 300, isRecordFieldReadOnly: isReadOnly, diff --git a/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx b/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx index 16b70692e0..5212783b0f 100644 --- a/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx +++ b/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx @@ -15,12 +15,12 @@ import { detectNumberFormat } from '@/localization/utils/detection/detectNumberF import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat'; import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone'; import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings'; import { CalendarStartDay } from 'twenty-shared/constants'; import { FirstDayOfTheWeek } from 'twenty-shared/types'; -jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({ - useUpdateOneRecord: jest.fn(), +jest.mock('@/settings/profile/hooks/useUpdateWorkspaceMemberSettings', () => ({ + useUpdateWorkspaceMemberSettings: jest.fn(), })); jest.mock('@/localization/utils/detection/detectTimeZone'); jest.mock('@/localization/utils/detection/detectDateFormat'); @@ -31,9 +31,10 @@ jest.mock( '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences', ); -const mockUseUpdateOneRecord = useUpdateOneRecord as jest.MockedFunction< - typeof useUpdateOneRecord ->; +const mockUseUpdateWorkspaceMemberSettings = + useUpdateWorkspaceMemberSettings as jest.MockedFunction< + typeof useUpdateWorkspaceMemberSettings + >; const mockDetectTimeZone = detectTimeZone as jest.MockedFunction< typeof detectTimeZone >; @@ -53,7 +54,7 @@ const mockGetWorkspaceMemberUpdateFromFormatPreferences = typeof getWorkspaceMemberUpdateFromFormatPreferences >; -const mockUpdateOneRecord = jest.fn(); +const mockUpdateWorkspaceMemberSettingsFn = jest.fn(); const mockCurrentWorkspaceMember = { id: 'workspace-member-1', @@ -94,8 +95,8 @@ describe('useFormatPreferences', () => { mockCurrentWorkspaceMember, ); - mockUseUpdateOneRecord.mockReturnValue({ - updateOneRecord: mockUpdateOneRecord, + mockUseUpdateWorkspaceMemberSettings.mockReturnValue({ + updateWorkspaceMemberSettings: mockUpdateWorkspaceMemberSettingsFn, }); mockDetectTimeZone.mockReturnValue('America/New_York'); @@ -105,7 +106,7 @@ describe('useFormatPreferences', () => { mockDetectCalendarStartDay.mockReturnValue(FirstDayOfTheWeek.MONDAY); mockGetWorkspaceMemberUpdateFromFormatPreferences.mockReturnValue({}); - mockUpdateOneRecord.mockResolvedValue({}); + mockUpdateWorkspaceMemberSettingsFn.mockResolvedValue(undefined); }); it('should be a function', () => { @@ -151,10 +152,9 @@ describe('useFormatPreferences', () => { await result.current.updateFormatPreference('timeZone', newTimeZone); }); - expect(mockUpdateOneRecord).toHaveBeenCalledWith({ - idToUpdate: mockCurrentWorkspaceMember.id, - objectNameSingular: 'workspaceMember', - updateOneRecordInput: { timeZone: newTimeZone }, + expect(mockUpdateWorkspaceMemberSettingsFn).toHaveBeenCalledWith({ + workspaceMemberId: mockCurrentWorkspaceMember.id, + update: { timeZone: newTimeZone }, }); }); @@ -173,10 +173,9 @@ describe('useFormatPreferences', () => { }); expect(mockDetectTimeZone).toHaveBeenCalled(); - expect(mockUpdateOneRecord).toHaveBeenCalledWith({ - idToUpdate: mockCurrentWorkspaceMember.id, - objectNameSingular: 'workspaceMember', - updateOneRecordInput: { timeZone: 'SYSTEM' }, + expect(mockUpdateWorkspaceMemberSettingsFn).toHaveBeenCalledWith({ + workspaceMemberId: mockCurrentWorkspaceMember.id, + update: { timeZone: 'SYSTEM' }, }); }); @@ -198,10 +197,9 @@ describe('useFormatPreferences', () => { await result.current.updateMultipleFormatPreferences(updates); }); - expect(mockUpdateOneRecord).toHaveBeenCalledWith({ - idToUpdate: mockCurrentWorkspaceMember.id, - objectNameSingular: 'workspaceMember', - updateOneRecordInput: { timeZone: 'Europe/Paris' }, + expect(mockUpdateWorkspaceMemberSettingsFn).toHaveBeenCalledWith({ + workspaceMemberId: mockCurrentWorkspaceMember.id, + update: { timeZone: 'Europe/Paris' }, }); }); @@ -216,7 +214,7 @@ describe('useFormatPreferences', () => { await result.current.updateFormatPreference('timeZone', 'Europe/London'); }); - expect(mockUpdateOneRecord).not.toHaveBeenCalled(); + expect(mockUpdateWorkspaceMemberSettingsFn).not.toHaveBeenCalled(); }); it('should handle update errors gracefully', async () => { @@ -225,7 +223,7 @@ describe('useFormatPreferences', () => { }); const error = new Error('Update failed'); - mockUpdateOneRecord.mockRejectedValue(error); + mockUpdateWorkspaceMemberSettingsFn.mockRejectedValue(error); await expect( act(async () => { diff --git a/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts b/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts index 972f825edd..dbea568c58 100644 --- a/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts +++ b/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts @@ -15,11 +15,9 @@ import { detectTimeFormat } from '@/localization/utils/detection/detectTimeForma import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone'; import { getFormatPreferencesFromWorkspaceMember } from '@/localization/utils/format-preferences/getFormatPreferencesFromWorkspaceMember'; import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { CalendarStartDay } from 'twenty-shared/constants'; import { logError } from '~/utils/logError'; @@ -31,11 +29,8 @@ export const useFormatPreferences = () => { setWorkspaceMemberFormatPreferences, ] = useAtomState(workspaceMemberFormatPreferencesState); const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); - const setCurrentWorkspaceMember = useSetAtomState( - currentWorkspaceMemberState, - ); - const { updateOneRecord } = useUpdateOneRecord(); + const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings(); const updateFormatPreference = useCallback( async ( @@ -96,19 +91,9 @@ export const useFormatPreferences = () => { }); try { - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: currentWorkspaceMember.id, - updateOneRecordInput: workspaceMemberUpdate, - }); - - // Update the currentWorkspaceMemberState with the new backend values - setCurrentWorkspaceMember((prev) => { - if (!prev) return prev; - return { - ...prev, - ...workspaceMemberUpdate, - }; + await updateWorkspaceMemberSettings({ + workspaceMemberId: currentWorkspaceMember.id, + update: workspaceMemberUpdate, }); } catch (error) { logError(error); @@ -117,9 +102,8 @@ export const useFormatPreferences = () => { }, [ currentWorkspaceMember, - updateOneRecord, + updateWorkspaceMemberSettings, setWorkspaceMemberFormatPreferences, - setCurrentWorkspaceMember, ], ); @@ -167,19 +151,9 @@ export const useFormatPreferences = () => { getWorkspaceMemberUpdateFromFormatPreferences(updates); try { - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: currentWorkspaceMember.id, - updateOneRecordInput: workspaceMemberUpdate, - }); - - // Update the currentWorkspaceMemberState with the new backend values - setCurrentWorkspaceMember((prev) => { - if (!prev) return prev; - return { - ...prev, - ...workspaceMemberUpdate, - }; + await updateWorkspaceMemberSettings({ + workspaceMemberId: currentWorkspaceMember.id, + update: workspaceMemberUpdate, }); } catch (error) { logError(error); @@ -188,9 +162,8 @@ export const useFormatPreferences = () => { }, [ currentWorkspaceMember, - updateOneRecord, + updateWorkspaceMemberSettings, setWorkspaceMemberFormatPreferences, - setCurrentWorkspaceMember, ], ); diff --git a/packages/twenty-front/src/modules/object-record/read-only/hooks/useIsRecordFieldReadOnly.ts b/packages/twenty-front/src/modules/object-record/read-only/hooks/useIsRecordFieldReadOnly.ts index f45691f6d0..ccda50fff1 100644 --- a/packages/twenty-front/src/modules/object-record/read-only/hooks/useIsRecordFieldReadOnly.ts +++ b/packages/twenty-front/src/modules/object-record/read-only/hooks/useIsRecordFieldReadOnly.ts @@ -1,4 +1,5 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition'; import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; @@ -39,10 +40,17 @@ export const useIsRecordFieldReadOnly = ({ return false; } + const fieldDefinition = formatFieldMetadataItemAsFieldDefinition({ + field: fieldMetadataItem, + objectMetadataItem, + }); + return isRecordFieldReadOnly({ isRecordReadOnly, isSystemObject: objectMetadataItem.isSystem, objectPermissions, fieldMetadataItem, + fieldDefinition, + objectPermissionsByObjectMetadataId, }); }; diff --git a/packages/twenty-front/src/modules/object-record/read-only/utils/__tests__/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.test.ts b/packages/twenty-front/src/modules/object-record/read-only/utils/__tests__/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.test.ts new file mode 100644 index 0000000000..ffc884daf6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/read-only/utils/__tests__/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.test.ts @@ -0,0 +1,82 @@ +import { isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission } from '@/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; + +const buildObjectPermissionsMap = (opts: { + sourceId: string; + targetId: string; + targetCanUpdate: boolean; +}) => ({ + [opts.sourceId]: { + objectMetadataId: opts.sourceId, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + restrictedFields: {}, + rowLevelPermissionPredicates: [], + rowLevelPermissionPredicateGroups: [], + }, + [opts.targetId]: { + objectMetadataId: opts.targetId, + canReadObjectRecords: true, + canUpdateObjectRecords: opts.targetCanUpdate, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + restrictedFields: {}, + rowLevelPermissionPredicates: [], + rowLevelPermissionPredicateGroups: [], + }, +}); + +describe('isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission', () => { + const sourceObjectMetadataId = 'source-object-id'; + const targetObjectMetadataId = 'workspace-member-object-id'; + + const oneToManyFieldDefinition = { + type: FieldMetadataType.RELATION, + fieldMetadataId: 'field-on-person', + label: 'yehe', + iconName: 'IconRelation', + metadata: { + fieldName: 'yehe', + relationType: RelationType.ONE_TO_MANY, + relationObjectMetadataId: targetObjectMetadataId, + relationObjectMetadataNameSingular: 'workspaceMember', + relationObjectMetadataNamePlural: 'workspaceMembers', + relationFieldMetadataId: 'target-field', + objectMetadataNameSingular: 'person', + targetFieldMetadataName: 'person', + settings: null, + isCustom: true, + isUIReadOnly: false, + }, + } as FieldDefinition; + + it('should return true when the related object cannot be updated', () => { + expect( + isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({ + fieldDefinition: oneToManyFieldDefinition, + objectPermissionsByObjectMetadataId: buildObjectPermissionsMap({ + sourceId: sourceObjectMetadataId, + targetId: targetObjectMetadataId, + targetCanUpdate: false, + }), + }), + ).toBe(true); + }); + + it('should return false when the related object can be updated', () => { + expect( + isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({ + fieldDefinition: oneToManyFieldDefinition, + objectPermissionsByObjectMetadataId: buildObjectPermissionsMap({ + sourceId: sourceObjectMetadataId, + targetId: targetObjectMetadataId, + targetCanUpdate: true, + }), + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.ts b/packages/twenty-front/src/modules/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.ts new file mode 100644 index 0000000000..ec74116dde --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.ts @@ -0,0 +1,67 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany'; +import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany'; +import { type ObjectPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +type ObjectPermissionsByObjectMetadataId = Record< + string, + ObjectPermissions & { objectMetadataId: string } +>; + +type IsOneToManyRelationFieldReadOnlyDueToTargetUpdatePermissionParams = { + fieldDefinition: FieldDefinition; + objectPermissionsByObjectMetadataId: ObjectPermissionsByObjectMetadataId; +}; + +// One-to-many edits persist by updating the related (or junction) record, not the +// source row — require canUpdate on that object metadata. +export const isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission = ({ + fieldDefinition, + objectPermissionsByObjectMetadataId, +}: IsOneToManyRelationFieldReadOnlyDueToTargetUpdatePermissionParams): boolean => { + if (isFieldRelationOneToMany(fieldDefinition)) { + const relationObjectMetadataId = + fieldDefinition.metadata.relationObjectMetadataId; + + if (!isNonEmptyString(relationObjectMetadataId)) { + return false; + } + + const relationObjectPermissions = getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + relationObjectMetadataId, + ); + + return relationObjectPermissions.canUpdateObjectRecords === false; + } + + if (isFieldMorphRelationOneToMany(fieldDefinition)) { + const morphTargetIds = [ + ...new Set( + fieldDefinition.metadata.morphRelations.map( + (relation) => relation.targetObjectMetadata.id, + ), + ), + ]; + + if (!isDefined(morphTargetIds[0])) { + return false; + } + + return morphTargetIds.every((targetObjectMetadataId) => { + const targetPermissions = getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + targetObjectMetadataId, + ); + + return targetPermissions.canUpdateObjectRecords === false; + }); + } + + return false; +}; diff --git a/packages/twenty-front/src/modules/object-record/read-only/utils/isRecordFieldReadOnly.ts b/packages/twenty-front/src/modules/object-record/read-only/utils/isRecordFieldReadOnly.ts index a8ec3deece..49156e3c6b 100644 --- a/packages/twenty-front/src/modules/object-record/read-only/utils/isRecordFieldReadOnly.ts +++ b/packages/twenty-front/src/modules/object-record/read-only/utils/isRecordFieldReadOnly.ts @@ -1,6 +1,16 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions'; +import { isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission } from '@/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; import { type ObjectPermission } from '~/generated-metadata/graphql'; +import { type ObjectPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +type ObjectPermissionsByObjectMetadataId = Record< + string, + ObjectPermissions & { objectMetadataId: string } +>; type IsRecordFieldReadOnlyParams = { isRecordReadOnly: boolean; @@ -10,6 +20,8 @@ type IsRecordFieldReadOnlyParams = { 'id' | 'isUIReadOnly' | 'isCustom' >; objectPermissions: ObjectPermission; + fieldDefinition?: FieldDefinition; + objectPermissionsByObjectMetadataId?: ObjectPermissionsByObjectMetadataId; }; export const isRecordFieldReadOnly = ({ @@ -17,16 +29,27 @@ export const isRecordFieldReadOnly = ({ isRecordReadOnly, isSystemObject, fieldMetadataItem, + fieldDefinition, + objectPermissionsByObjectMetadataId, }: IsRecordFieldReadOnlyParams) => { const fieldReadOnlyByPermissions = isFieldMetadataReadOnlyByPermissions({ objectPermissions, fieldMetadataId: fieldMetadataItem.id, }); + const oneToManyTargetReadOnly = + isDefined(fieldDefinition) && + isDefined(objectPermissionsByObjectMetadataId) && + isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({ + fieldDefinition, + objectPermissionsByObjectMetadataId, + }); + return ( isRecordReadOnly || (isSystemObject === true && fieldMetadataItem.isCustom !== true) || fieldMetadataItem.isUIReadOnly || - fieldReadOnlyByPermissions + fieldReadOnlyByPermissions || + oneToManyTargetReadOnly ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx index 6e97b124af..07ec78bab2 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardBody.tsx @@ -28,6 +28,7 @@ export const RecordBoardCardBody = () => { const { labelIdentifierFieldMetadataItem, fieldDefinitionByFieldMetadataItemId, + objectPermissionsByObjectMetadataId, } = useRecordIndexContextOrThrow(); const useUpdateOneRecordHook: RecordUpdateHook = () => { @@ -83,6 +84,8 @@ export const RecordBoardCardBody = () => { isCustom: correspondingFieldDefinition.metadata.isCustom ?? false, }, + fieldDefinition: correspondingFieldDefinition, + objectPermissionsByObjectMetadataId, }), fieldDefinition: correspondingFieldDefinition, useUpdateRecord: useUpdateOneRecordHook, diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardBody.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardBody.tsx index dc4344086d..b7c9e372fe 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardBody.tsx @@ -48,6 +48,7 @@ export const RecordCalendarCardBody = ({ const { labelIdentifierFieldMetadataItem, fieldDefinitionByFieldMetadataItemId, + objectPermissionsByObjectMetadataId, } = useRecordIndexContextOrThrow(); const visibleRecordFields = useAtomComponentSelectorValue( @@ -92,6 +93,8 @@ export const RecordCalendarCardBody = ({ isCustom: correspondingFieldDefinition.metadata.isCustom ?? false, }, + fieldDefinition: correspondingFieldDefinition, + objectPermissionsByObjectMetadataId, }), fieldDefinition: correspondingFieldDefinition, useUpdateRecord: useUpdateOneRecordHook, diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx index 3407e535aa..ef8580c873 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx @@ -95,28 +95,87 @@ export const RecordFieldList = ({ > {legacyActivityTargetFieldMetadataItems?.map( - (fieldMetadataItem, index) => ( + (fieldMetadataItem, index) => { + const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: index, + objectMetadataItem, + showLabel: true, + labelWidth: 90, + }); + + return ( + handleMouseEnter(index), + anchorId: `${getRecordFieldInputInstanceId({ + recordId: objectRecordId, + fieldName: fieldMetadataItem.name, + prefix: instanceId, + })}`, + isRecordFieldReadOnly: isRecordFieldReadOnly({ + isRecordReadOnly, + isSystemObject: objectMetadataItem.isSystem, + objectPermissions: + getObjectPermissionsFromMapByObjectMetadataId({ + objectPermissionsByObjectMetadataId, + objectMetadataId: objectMetadataItem.id, + }), + fieldMetadataItem: { + id: fieldMetadataItem.id, + isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false, + isCustom: fieldMetadataItem.isCustom ?? false, + }, + fieldDefinition, + objectPermissionsByObjectMetadataId, + }), + }} + > + + + ); + }, + )} + {inlineFieldMetadataItems?.map((fieldMetadataItem, index) => { + const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: index, + objectMetadataItem, + showLabel: true, + labelWidth: 90, + }); + + return ( handleMouseEnter(index), - anchorId: `${getRecordFieldInputInstanceId({ - recordId: objectRecordId, - fieldName: fieldMetadataItem.name, - prefix: instanceId, - })}`, isRecordFieldReadOnly: isRecordFieldReadOnly({ isRecordReadOnly, isSystemObject: objectMetadataItem.isSystem, @@ -130,90 +189,44 @@ export const RecordFieldList = ({ isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false, isCustom: fieldMetadataItem.isCustom ?? false, }, + fieldDefinition, + objectPermissionsByObjectMetadataId, }), - }} - > - + handleMouseEnter( + index + + (legacyActivityTargetFieldMetadataItems?.length ?? 0), + ), + anchorId: `${getRecordFieldInputInstanceId({ recordId: objectRecordId, fieldName: fieldMetadataItem.name, prefix: instanceId, - })} - activityObjectNameSingular={ - objectNameSingular as - | CoreObjectNameSingular.Note - | CoreObjectNameSingular.Task - } - activityRecordId={objectRecordId} - showLabel={true} - maxWidth={200} - /> - - ), - )} - {inlineFieldMetadataItems?.map((fieldMetadataItem, index) => ( - + - handleMouseEnter( - index + (legacyActivityTargetFieldMetadataItems?.length ?? 0), - ), - anchorId: `${getRecordFieldInputInstanceId({ - recordId: objectRecordId, - fieldName: fieldMetadataItem.name, - prefix: instanceId, - })}`, - isForbidden: isJunctionRelationForbidden({ - fieldMetadataItem, - sourceObjectMetadataId: objectMetadataItem.id, - objectMetadataItems, - objectPermissionsByObjectMetadataId, - }), - }} - > - - - - - ))} + }} + > + + + + ); + })} {showDuplicatesSection && ( ( - - {fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION ? ( - - ) : ( - - )} - - ))} + .map((fieldMetadataItem, index) => { + const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: index, + objectMetadataItem, + }); + + return ( + + {fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION ? ( + + ) : ( + + )} + + ); + })} { isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false, isCustom: fieldMetadataItem.isCustom ?? false, }, + fieldDefinition, + objectPermissionsByObjectMetadataId, }), anchorId: recordFieldInputInstanceId, } satisfies GenericFieldContextType; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx index 6922301389..fdeb99ff9e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx @@ -100,6 +100,8 @@ export const FieldWidgetDisplay = ({ isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false, isCustom: fieldMetadataItem.isCustom ?? false, }, + fieldDefinition, + objectPermissionsByObjectMetadataId, }), onMouseEnter: handleMouseEnter, anchorId: getRecordFieldInputInstanceId({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldItem.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldItem.tsx index 1c1924afee..75939165e9 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldItem.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldItem.tsx @@ -55,6 +55,14 @@ export const FieldsWidgetFieldItem = ({ targetObjectNameSingular, ); + const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: globalIndex, + objectMetadataItem, + showLabel: true, + labelWidth: 90, + }); + return ( { const { t } = useLingui(); const currentUser = useAtomStateValue(currentUserState); - const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState( - currentWorkspaceMemberState, - ); + const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); const { canEdit: canEditFirstName } = useCanEditProfileField('firstName'); const { canEdit: canEditLastName } = useCanEditProfileField('lastName'); @@ -42,7 +38,7 @@ export const NameFields = ({ autoSave = true }: NameFieldsProps) => { currentWorkspaceMember?.name?.lastName ?? '', ); - const { updateOneRecord } = useUpdateOneRecord(); + const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings(); // TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com) const debouncedUpdate = useDebouncedCallback(async () => { @@ -52,24 +48,15 @@ export const NameFields = ({ autoSave = true }: NameFieldsProps) => { } if (autoSave) { - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: currentWorkspaceMember?.id, - updateOneRecordInput: { + await updateWorkspaceMemberSettings({ + workspaceMemberId: currentWorkspaceMember.id, + update: { name: { firstName: firstName, lastName: lastName, }, }, }); - - setCurrentWorkspaceMember({ - ...currentWorkspaceMember, - name: { - firstName, - lastName, - }, - }); } } catch (error) { logError(error); diff --git a/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateWorkspaceMemberSettings.ts b/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateWorkspaceMemberSettings.ts new file mode 100644 index 0000000000..7972488020 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateWorkspaceMemberSettings.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_WORKSPACE_MEMBER_SETTINGS = gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateWorkspaceMemberSettings.ts b/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateWorkspaceMemberSettings.ts new file mode 100644 index 0000000000..65d302b210 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateWorkspaceMemberSettings.ts @@ -0,0 +1,61 @@ +import { useMutation } from '@apollo/client/react'; + +import { + type CurrentWorkspaceMember, + currentWorkspaceMemberState, +} from '@/auth/states/currentWorkspaceMemberState'; +import { UPDATE_WORKSPACE_MEMBER_SETTINGS } from '@/settings/profile/graphql/mutations/updateWorkspaceMemberSettings'; +import { + mergeWorkspaceMemberSettingsIntoCurrent, + type WorkspaceMemberSettingsUpdateInput, +} from '@/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; + +type UpdateWorkspaceMemberSettingsMutationData = { + updateWorkspaceMemberSettings: boolean; +}; + +export type { WorkspaceMemberSettingsUpdateInput }; + +export const useUpdateWorkspaceMemberSettings = () => { + const [updateWorkspaceMemberSettingsMutation] = + useMutation( + UPDATE_WORKSPACE_MEMBER_SETTINGS, + ); + const setCurrentWorkspaceMember = useSetAtomState( + currentWorkspaceMemberState, + ); + + const updateWorkspaceMemberSettings = async ({ + workspaceMemberId, + update, + }: { + workspaceMemberId: string; + update: + | WorkspaceMemberSettingsUpdateInput + | Partial + | Record; + }) => { + const { data } = await updateWorkspaceMemberSettingsMutation({ + variables: { + input: { + workspaceMemberId, + update, + }, + }, + }); + + if (data?.updateWorkspaceMemberSettings === true) { + setCurrentWorkspaceMember((previous) => { + if (!previous || previous.id !== workspaceMemberId) { + return previous; + } + return mergeWorkspaceMemberSettingsIntoCurrent(previous, update); + }); + } + }; + + return { + updateWorkspaceMemberSettings, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/profile/utils/__tests__/mergeWorkspaceMemberSettingsIntoCurrent.test.ts b/packages/twenty-front/src/modules/settings/profile/utils/__tests__/mergeWorkspaceMemberSettingsIntoCurrent.test.ts new file mode 100644 index 0000000000..7380ce9813 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/utils/__tests__/mergeWorkspaceMemberSettingsIntoCurrent.test.ts @@ -0,0 +1,146 @@ +import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; +import { mergeWorkspaceMemberSettingsIntoCurrent } from '@/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent'; + +const createBaseWorkspaceMember = (): CurrentWorkspaceMember => ({ + id: 'workspace-member-id', + name: { + firstName: 'Jane', + lastName: 'Doe', + }, + locale: 'en', + colorScheme: 'System', + userEmail: 'jane@example.com', + avatarUrl: 'https://example.com/avatar.png', + timeZone: 'Europe/London', + dateFormat: null, + timeFormat: null, + numberFormat: null, + calendarStartDay: 1, +}); + +describe('mergeWorkspaceMemberSettingsIntoCurrent', () => { + it('should not mutate the previous object', () => { + const previous = createBaseWorkspaceMember(); + const update = { locale: 'fr-FR' }; + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, update); + + expect(result).not.toBe(previous); + expect(previous.locale).toBe('en'); + expect(result.locale).toBe('fr-FR'); + }); + + it('should merge locale and colorScheme', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + locale: 'de-DE', + colorScheme: 'Dark', + }); + + expect(result.locale).toBe('de-DE'); + expect(result.colorScheme).toBe('Dark'); + expect(result.userEmail).toBe(previous.userEmail); + expect(result.id).toBe(previous.id); + }); + + it('should merge name when payload only includes firstName key', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + name: { firstName: 'Janet' }, + } as Record); + + expect(result.name.firstName).toBe('Janet'); + expect(result.name.lastName).toBe('Doe'); + }); + + it('should merge name when payload only includes lastName key', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + name: { lastName: 'Smith' }, + } as Record); + + expect(result.name.firstName).toBe('Jane'); + expect(result.name.lastName).toBe('Smith'); + }); + + it('should merge both name fields when both are non-empty strings', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + name: { firstName: 'A', lastName: 'B' }, + }); + + expect(result.name).toEqual({ firstName: 'A', lastName: 'B' }); + }); + + it('should set avatarUrl to null when update is empty string', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + avatarUrl: '', + }); + + expect(result.avatarUrl).toBeNull(); + }); + + it('should set avatarUrl to null when update is null', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + avatarUrl: null, + }); + + expect(result.avatarUrl).toBeNull(); + }); + + it('should set avatarUrl to the new URL when provided', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + avatarUrl: 'https://example.com/new.png', + }); + + expect(result.avatarUrl).toBe('https://example.com/new.png'); + }); + + it('should merge timeZone and calendarStartDay', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + timeZone: 'America/New_York', + calendarStartDay: 0, + }); + + expect(result.timeZone).toBe('America/New_York'); + expect(result.calendarStartDay).toBe(0); + }); + + it('should merge format fields from Record payload', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + dateFormat: 'MONTH_FIRST', + timeFormat: 'HOUR_12', + numberFormat: 'COMMAS_AND_DOT', + } as Record); + + expect(result.dateFormat).toBe('MONTH_FIRST'); + expect(result.timeFormat).toBe('HOUR_12'); + expect(result.numberFormat).toBe('COMMAS_AND_DOT'); + }); + + it('should ignore unknown keys in Record payload', () => { + const previous = createBaseWorkspaceMember(); + + const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, { + locale: 'es', + unknownField: 'ignored', + } as Record); + + expect(result.locale).toBe('es'); + expect('unknownField' in result).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent.ts b/packages/twenty-front/src/modules/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent.ts new file mode 100644 index 0000000000..4f9f50b3f8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent.ts @@ -0,0 +1,171 @@ +import { isNull, isNumber, isString } from '@sniptt/guards'; + +import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; +import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember'; +import { isDefined, isPlainObject } from 'twenty-shared/utils'; +import { + WorkspaceMemberDateFormatEnum, + WorkspaceMemberNumberFormatEnum, + WorkspaceMemberTimeFormatEnum, +} from '~/generated-metadata/graphql'; + +export type WorkspaceMemberNameUpdate = { + firstName: string; + lastName: string; +}; + +export type WorkspaceMemberSettingsUpdateInput = { + name?: WorkspaceMemberNameUpdate; + colorScheme?: string; + avatarUrl?: string | null; + locale?: string; + calendarStartDay?: number; + timeZone?: string; + dateFormat?: string; + timeFormat?: string; + numberFormat?: string; + position?: number; +}; + +const isColorScheme = (value: unknown): value is ColorScheme => + value === 'Dark' || value === 'Light' || value === 'System'; + +const WORKSPACE_MEMBER_DATE_FORMAT_VALUES: ReadonlySet = new Set( + Object.values(WorkspaceMemberDateFormatEnum), +); + +const isWorkspaceMemberDateFormat = ( + value: unknown, +): value is NonNullable => + isString(value) && WORKSPACE_MEMBER_DATE_FORMAT_VALUES.has(value); + +const WORKSPACE_MEMBER_TIME_FORMAT_VALUES: ReadonlySet = new Set( + Object.values(WorkspaceMemberTimeFormatEnum), +); + +const isWorkspaceMemberTimeFormat = ( + value: unknown, +): value is NonNullable => + isString(value) && WORKSPACE_MEMBER_TIME_FORMAT_VALUES.has(value); + +const WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES: ReadonlySet = new Set( + Object.values(WorkspaceMemberNumberFormatEnum), +); + +const isWorkspaceMemberNumberFormat = ( + value: unknown, +): value is NonNullable => + isString(value) && WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES.has(value); + +export const mergeWorkspaceMemberSettingsIntoCurrent = ( + previous: CurrentWorkspaceMember, + update: WorkspaceMemberSettingsUpdateInput | Record, +): CurrentWorkspaceMember => { + if (!isPlainObject(update)) { + return { ...previous }; + } + + const payload = update; + let next: CurrentWorkspaceMember = { ...previous }; + + if ('name' in payload && isPlainObject(payload.name)) { + const namePayload = payload.name; + const firstName = isString(namePayload.firstName) + ? namePayload.firstName + : undefined; + const lastName = isString(namePayload.lastName) + ? namePayload.lastName + : undefined; + next = { + ...next, + name: { + ...previous.name, + ...(isDefined(firstName) ? { firstName } : {}), + ...(isDefined(lastName) ? { lastName } : {}), + }, + }; + } + + if ( + 'locale' in payload && + (payload.locale === null || isDefined(payload.locale)) + ) { + if (isString(payload.locale) || isNull(payload.locale)) { + next = { ...next, locale: payload.locale }; + } + } + + if ('colorScheme' in payload && isDefined(payload.colorScheme)) { + if (isColorScheme(payload.colorScheme)) { + next = { ...next, colorScheme: payload.colorScheme }; + } + } + + if ('avatarUrl' in payload) { + const value = payload.avatarUrl; + if (value === '' || isNull(value)) { + next = { ...next, avatarUrl: null }; + } else if (isString(value)) { + next = { ...next, avatarUrl: value }; + } + } + + if ( + 'timeZone' in payload && + (isNull(payload.timeZone) || isDefined(payload.timeZone)) + ) { + if (isString(payload.timeZone) || isNull(payload.timeZone)) { + next = { ...next, timeZone: payload.timeZone }; + } + } + + if ( + 'dateFormat' in payload && + (payload.dateFormat === null || isDefined(payload.dateFormat)) + ) { + const value = payload.dateFormat; + if (isNull(value)) { + next = { ...next, dateFormat: null }; + } else if (isWorkspaceMemberDateFormat(value)) { + next = { ...next, dateFormat: value }; + } + } + + if ( + 'timeFormat' in payload && + (payload.timeFormat === null || isDefined(payload.timeFormat)) + ) { + const value = payload.timeFormat; + if (isNull(value)) { + next = { ...next, timeFormat: null }; + } else if (isWorkspaceMemberTimeFormat(value)) { + next = { ...next, timeFormat: value }; + } + } + + if ( + 'numberFormat' in payload && + (payload.numberFormat === null || isDefined(payload.numberFormat)) + ) { + const value = payload.numberFormat; + if (isNull(value)) { + next = { ...next, numberFormat: null }; + } else if (isWorkspaceMemberNumberFormat(value)) { + next = { ...next, numberFormat: value }; + } + } + + if ( + 'calendarStartDay' in payload && + (isNull(payload.calendarStartDay) || isDefined(payload.calendarStartDay)) + ) { + const value = payload.calendarStartDay; + if (isNull(value)) { + next = { ...next, calendarStartDay: null }; + } else if (isNumber(value) && Number.isFinite(value)) { + next = { ...next, calendarStartDay: value }; + } + } + + return next; +}; diff --git a/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx b/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx index ad0cacb8b4..a91d64c449 100644 --- a/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx +++ b/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx @@ -2,14 +2,13 @@ import { t } from '@lingui/core/macro'; import { useState } from 'react'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField'; +import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ImageInput } from '@/ui/input/components/ImageInput'; -import { isDefined } from 'twenty-shared/utils'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useMutation } from '@apollo/client/react'; +import { isDefined } from 'twenty-shared/utils'; import { UploadWorkspaceMemberProfilePictureDocument } from '~/generated-metadata/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; @@ -32,15 +31,13 @@ export const WorkspaceMemberPictureUploader = ({ const [uploadController, setUploadController] = useState(null); - const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState( - currentWorkspaceMemberState, - ); + const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); const [uploadPicture] = useMutation( UploadWorkspaceMemberProfilePictureDocument, ); - const { updateOneRecord } = useUpdateOneRecord(); + const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings(); const { canEdit: canEditProfilePicture } = useCanEditProfileField('profilePicture'); @@ -74,20 +71,11 @@ export const WorkspaceMemberPictureUploader = ({ throw new Error('Avatar upload failed'); } - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: workspaceMemberId, - updateOneRecordInput: { avatarUrl: signedFile.url }, - }); - newAvatarUrl = signedFile.url; - - if (isEditingSelf && isDefined(currentWorkspaceMember)) { - setCurrentWorkspaceMember({ - ...currentWorkspaceMember, - avatarUrl: newAvatarUrl, - }); - } + await updateWorkspaceMemberSettings({ + workspaceMemberId, + update: { avatarUrl: newAvatarUrl }, + }); if (isDefined(onAvatarUpdated)) { onAvatarUpdated(newAvatarUrl); @@ -114,19 +102,11 @@ export const WorkspaceMemberPictureUploader = ({ setErrorMessage(null); try { - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: workspaceMemberId, - updateOneRecordInput: { avatarUrl: '' }, + await updateWorkspaceMemberSettings({ + workspaceMemberId, + update: { avatarUrl: '' }, }); - if (isEditingSelf && isDefined(currentWorkspaceMember)) { - setCurrentWorkspaceMember({ - ...currentWorkspaceMember, - avatarUrl: null, - }); - } - if (isDefined(onAvatarUpdated)) { onAvatarUpdated(null); } diff --git a/packages/twenty-front/src/modules/ui/theme/hooks/__tests__/useColorScheme.test.tsx b/packages/twenty-front/src/modules/ui/theme/hooks/__tests__/useColorScheme.test.tsx index 7970be7be9..f12a499595 100644 --- a/packages/twenty-front/src/modules/ui/theme/hooks/__tests__/useColorScheme.test.tsx +++ b/packages/twenty-front/src/modules/ui/theme/hooks/__tests__/useColorScheme.test.tsx @@ -7,11 +7,11 @@ import { import { useColorScheme } from '@/ui/theme/hooks/useColorScheme'; import { resetJotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; -const updateOneRecordMock = jest.fn(); +const mockUpdateWorkspaceMemberSettings = jest.fn(); -jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({ - useUpdateOneRecord: () => ({ - updateOneRecord: updateOneRecordMock, +jest.mock('@/settings/profile/hooks/useUpdateWorkspaceMemberSettings', () => ({ + useUpdateWorkspaceMemberSettings: () => ({ + updateWorkspaceMemberSettings: mockUpdateWorkspaceMemberSettings, }), })); diff --git a/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts b/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts index 69795b63a5..6151b58865 100644 --- a/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts +++ b/packages/twenty-front/src/modules/ui/theme/hooks/useColorScheme.ts @@ -1,8 +1,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useCallback } from 'react'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { persistedColorSchemeState } from '@/ui/theme/states/persistedColorSchemeState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember'; @@ -18,7 +17,7 @@ export const useColorScheme = () => { currentWorkspaceMemberState, ); - const { updateOneRecord } = useUpdateOneRecord(); + const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings(); const setPersistedColorScheme = useSetAtomState(persistedColorSchemeState); const colorScheme = currentWorkspaceMember?.colorScheme ?? 'System'; @@ -38,10 +37,9 @@ export const useColorScheme = () => { colorScheme: value, }; }); - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: currentWorkspaceMember?.id, - updateOneRecordInput: { + await updateWorkspaceMemberSettings({ + workspaceMemberId: currentWorkspaceMember.id, + update: { colorScheme: value, }, }); @@ -50,7 +48,7 @@ export const useColorScheme = () => { currentWorkspaceMember, setCurrentWorkspaceMember, setPersistedColorScheme, - updateOneRecord, + updateWorkspaceMemberSettings, ], ); diff --git a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx index 5fa7cc54f3..1070be13e5 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx @@ -1,9 +1,9 @@ -import { styled } from '@linaria/react'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { zodResolver } from '@hookform/resolvers/zod'; +import { styled } from '@linaria/react'; import { useCallback, useState } from 'react'; import { Controller, type SubmitHandler, useForm } from 'react-hook-form'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { Key } from 'ts-key-enum'; import { z } from 'zod'; @@ -12,14 +12,12 @@ import { Title } from '@/auth/components/Title'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; +import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings'; import { WorkspaceMemberPictureUploader } from '@/settings/workspace-member/components/WorkspaceMemberPictureUploader'; import { PageFocusId } from '@/types/PageFocusId'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInput } from '@/ui/input/components/TextInput'; -import { ModalContent } from 'twenty-ui/layout'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { CombinedGraphQLErrors } from '@apollo/client/errors'; import { i18n } from '@lingui/core'; @@ -28,6 +26,7 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { isDefined } from 'twenty-shared/utils'; import { H2Title } from 'twenty-ui/display'; import { MainButton } from 'twenty-ui/input'; +import { ModalContent } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledContentContainer = styled.div` @@ -71,14 +70,12 @@ export const CreateProfile = () => { const { t } = useLingui(); const setNextOnboardingStatus = useSetNextOnboardingStatus(); const { enqueueErrorSnackBar } = useSnackBar(); - const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState( - currentWorkspaceMemberState, - ); + const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); const setCurrentUser = useSetAtomState(currentUserState); const setCurrentWorkspaceMembers = useSetAtomState( currentWorkspaceMembersState, ); - const { updateOneRecord } = useUpdateOneRecord(); + const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings(); // Form const { @@ -105,10 +102,9 @@ export const CreateProfile = () => { throw new Error('First name or last name is missing'); } - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: currentWorkspaceMember?.id, - updateOneRecordInput: { + await updateWorkspaceMemberSettings({ + workspaceMemberId: currentWorkspaceMember.id, + update: { name: { firstName: data.firstName, lastName: data.lastName, @@ -117,21 +113,6 @@ export const CreateProfile = () => { }, }); - setCurrentWorkspaceMember((current) => { - if (isDefined(current)) { - return { - ...current, - name: { - firstName: data.firstName, - lastName: data.lastName, - }, - - colorScheme: 'System', - }; - } - return current; - }); - setCurrentWorkspaceMembers((members) => members.map((member) => member.id === currentWorkspaceMember?.id @@ -141,6 +122,7 @@ export const CreateProfile = () => { firstName: data.firstName, lastName: data.lastName, }, + colorScheme: 'System', } : member, ), @@ -168,10 +150,9 @@ export const CreateProfile = () => { currentWorkspaceMember?.id, setNextOnboardingStatus, enqueueErrorSnackBar, - setCurrentWorkspaceMember, setCurrentWorkspaceMembers, setCurrentUser, - updateOneRecord, + updateWorkspaceMemberSettings, ], ); diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx index 32105f7202..fa7cb26b73 100644 --- a/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx @@ -2,10 +2,9 @@ import { styled } from '@linaria/react'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale'; import { Select } from '@/ui/input/components/Select'; +import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings'; import { useInvalidateMetadataStore } from '@/metadata-store/hooks/useInvalidateMetadataStore'; import { useStore } from 'jotai'; @@ -30,7 +29,7 @@ export const LocalePicker = () => { const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState( currentWorkspaceMemberState, ); - const { updateOneRecord } = useUpdateOneRecord(); + const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings(); const { invalidateMetadataStore } = useInvalidateMetadataStore(); @@ -40,10 +39,9 @@ export const LocalePicker = () => { } try { - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: currentWorkspaceMember.id, - updateOneRecordInput: changedFields, + await updateWorkspaceMemberSettings({ + workspaceMemberId: currentWorkspaceMember.id, + update: changedFields, }); } catch (error) { logError(error); diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts index 78cedd390b..631083f402 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts @@ -3,19 +3,19 @@ import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { ApprovedAccessDomainExceptionFilter } from 'src/engine/core-modules/approved-access-domain/approved-access-domain-exception-filter'; import { ApprovedAccessDomainDTO } from 'src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto'; import { CreateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input'; import { DeleteApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input'; import { ValidateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input'; import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; -import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts index 0de58a4262..138548d170 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts @@ -34,6 +34,7 @@ export class UpdateSubscriptionQuantityJob { await this.globalWorkspaceOrmManager.getRepository( data.workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); const workspaceMembersCount = await workspaceMemberRepository.count(); diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index 022d2faf58..d17b93be1a 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -4,6 +4,7 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; @@ -11,7 +12,6 @@ import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise. import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; -import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard'; import { UserWorkspaceEntityCacheProviderService } from 'src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; @@ -21,6 +21,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; @@ -35,6 +36,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works UserWorkspaceEntity, WorkspaceEntity, RoleTargetEntity, + RoleEntity, ]), RoleValidationModule, NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]), @@ -51,15 +53,12 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works OnboardingModule, EnterpriseModule, FeatureFlagModule, + CoreEntityCacheModule, ], services: [UserWorkspaceService], }), ], exports: [UserWorkspaceService], - providers: [ - UserWorkspaceService, - UserWorkspaceEntityCacheProviderService, - UploadProfilePicturePermissionGuard, - ], + providers: [UserWorkspaceService, UserWorkspaceEntityCacheProviderService], }) export class UserWorkspaceModule {} diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index 7a134086cd..47688d54cd 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -5,6 +5,7 @@ import { type DataSource, type Repository } from 'typeorm'; import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; @@ -138,6 +139,12 @@ describe('UserWorkspaceService', () => { setOnboardingCreateProfilePending: jest.fn(), }, }, + { + provide: CoreEntityCacheService, + useValue: { + invalidate: jest.fn().mockResolvedValue(undefined), + }, + }, { provide: FeatureFlagService, useValue: { diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 4168a474a4..c9d9ab6415 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -7,6 +7,7 @@ import { FileFolder } from 'twenty-shared/types'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { IsNull, Not, type QueryRunner, type Repository } from 'typeorm'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; @@ -62,10 +63,37 @@ export class UserWorkspaceService extends TypeOrmQueryService { + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { + id: userWorkspaceId, + }, + }); + + if (!isDefined(userWorkspace)) { + return; + } + + userWorkspace.locale = locale; + await this.userWorkspaceRepository.save(userWorkspace); + + await this.coreEntityCacheService.invalidate( + 'userWorkspaceEntity', + userWorkspaceId, + ); + } + async create( { userId, diff --git a/packages/twenty-server/src/engine/core-modules/user/dtos/update-workspace-member-settings.input.ts b/packages/twenty-server/src/engine/core-modules/user/dtos/update-workspace-member-settings.input.ts new file mode 100644 index 0000000000..c43f78dc8c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/dtos/update-workspace-member-settings.input.ts @@ -0,0 +1,17 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsObject, IsUUID } from 'class-validator'; +import GraphQLJSON from 'graphql-type-json'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class UpdateWorkspaceMemberSettingsInput { + @Field(() => UUIDScalarType) + @IsUUID() + workspaceMemberId: string; + + @Field(() => GraphQLJSON) + @IsObject() + update: Record; +} diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index 5af477c661..a5d33e4f22 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -5,6 +5,7 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; +import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module'; @@ -25,7 +26,6 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; -import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { userAutoResolverOpts } from './user.auto-resolver-opts'; diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index c29afc408b..7a3d4c775b 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -20,6 +20,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.dto'; +import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; import { OnboardingService, @@ -31,6 +32,7 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { DeletedWorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto'; import { UpdateUserEmailInput } from 'src/engine/core-modules/user/dtos/update-user-email.input'; +import { UpdateWorkspaceMemberSettingsInput } from 'src/engine/core-modules/user/dtos/update-workspace-member-settings.input'; import { WorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { @@ -38,15 +40,16 @@ import { WorkspaceMemberTranspiler, } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service'; import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service'; -import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; +import { assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly } from 'src/engine/core-modules/user/utils/assert-workspace-member-update-non-custom-fields.util'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator'; import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; @@ -437,6 +440,106 @@ export class UserResolver { }); } + @Mutation(() => Boolean) + @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) + async updateWorkspaceMemberSettings( + @Args('input') input: UpdateWorkspaceMemberSettingsInput, + @AuthWorkspace() workspace: WorkspaceEntity, + @AuthUserWorkspaceId({ allowUndefined: true }) userWorkspaceId?: string, + @AuthApiKey() apiKey?: ApiKeyEntity, + @AuthWorkspaceMemberId() authenticatedWorkspaceMemberId?: string, + @AuthUser({ allowUndefined: true }) user?: AuthContextUser | null, + ): Promise { + let isUpdatingSelf = + isDefined(authenticatedWorkspaceMemberId) && + authenticatedWorkspaceMemberId === input.workspaceMemberId; + + // for signup + if (!isUpdatingSelf && isDefined(user?.id) && !isDefined(apiKey)) { + const targetWorkspaceMember = + await this.userWorkspaceService.getWorkspaceMemberOrThrow({ + workspaceMemberId: input.workspaceMemberId, + workspaceId: workspace.id, + }); + + isUpdatingSelf = targetWorkspaceMember.userId === user.id; + } + + const canUpdateWorkspaceMember = + isUpdatingSelf || + (await this.permissionsService.userHasWorkspaceSettingPermission({ + userWorkspaceId, + workspaceId: workspace.id, + setting: PermissionFlagType.WORKSPACE_MEMBERS, + apiKeyId: apiKey?.id, + })); + + if (!canUpdateWorkspaceMember) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + { + userFriendlyMessage: msg`You do not have permission to update this workspace member.`, + }, + ); + } + + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ + update: input.update, + }); + + const workspaceMemberRepository = + await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => + this.globalWorkspaceOrmManager.getRepository( + workspace.id, + 'workspaceMember', + { + shouldBypassPermissionChecks: true, + }, + ), + ); + + const workspaceMember = + await this.globalWorkspaceOrmManager.executeInWorkspaceContext(() => + workspaceMemberRepository.findOne({ + where: { + id: input.workspaceMemberId, + }, + }), + ); + + if (!isDefined(workspaceMember)) { + throw new BadRequestException('Workspace member not found'); + } + + const workspaceMemberUpdatePayload: Partial = + { + id: workspaceMember.id, + ...(input.update as Partial), + }; + + await this.globalWorkspaceOrmManager.executeInWorkspaceContext(() => + workspaceMemberRepository.save(workspaceMemberUpdatePayload), + ); + + if (isDefined(input.update.locale)) { + const targetUserWorkspace = + await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({ + userId: workspaceMember.userId, + workspaceId: workspace.id, + }); + + await this.userWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace( + { + locale: input.update.locale as UserWorkspaceEntity['locale'], + userWorkspaceId: targetUserWorkspace.id, + }, + ); + } + + return true; + } + @ResolveField(() => OnboardingStatus, { nullable: true, }) diff --git a/packages/twenty-server/src/engine/core-modules/user/utils/__tests__/assert-workspace-member-update-non-custom-fields.util.spec.ts b/packages/twenty-server/src/engine/core-modules/user/utils/__tests__/assert-workspace-member-update-non-custom-fields.util.spec.ts new file mode 100644 index 0000000000..e89f78dc07 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/utils/__tests__/assert-workspace-member-update-non-custom-fields.util.spec.ts @@ -0,0 +1,85 @@ +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly } from 'src/engine/core-modules/user/utils/assert-workspace-member-update-non-custom-fields.util'; + +describe('assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly', () => { + it('should throw when the update payload is empty', () => { + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ update: {} }), + ).toThrow(UserInputError); + + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ update: {} }), + ).toThrow('Update payload cannot be empty'); + }); + + it('should not throw when all top-level keys are standard workspaceMember fields', () => { + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ + update: { + timeZone: 'Europe/Paris', + locale: 'en', + }, + }), + ).not.toThrow(); + }); + + it.each(['userId', 'id'] as const)( + 'should throw when the update includes %s', + (fieldName) => { + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ + update: { + [fieldName]: 'value', + }, + }), + ).toThrow(UserInputError); + + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ + update: { + [fieldName]: 'value', + }, + }), + ).toThrow( + `Cannot update custom workspaceMember field via this endpoint: ${fieldName}`, + ); + }, + ); + + it('should throw when a top-level key is not in the standard field allowlist', () => { + const unknownKey = 'notAWorkspaceMemberField'; + + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ + update: { + [unknownKey]: 'value', + }, + }), + ).toThrow(UserInputError); + + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ + update: { + [unknownKey]: 'value', + }, + }), + ).toThrow( + `Cannot update custom workspaceMember field via this endpoint: ${unknownKey}`, + ); + }); + + it('should reject the payload when a disallowed key is mixed with allowed keys', () => { + const unknownKey = 'typoTimeZone'; + + expect(() => + assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ + update: { + timeZone: 'Europe/Paris', + [unknownKey]: 'x', + }, + }), + ).toThrow( + `Cannot update custom workspaceMember field via this endpoint: ${unknownKey}`, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/user/utils/assert-workspace-member-update-non-custom-fields.util.ts b/packages/twenty-server/src/engine/core-modules/user/utils/assert-workspace-member-update-non-custom-fields.util.ts new file mode 100644 index 0000000000..e8ae7986b7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/utils/assert-workspace-member-update-non-custom-fields.util.ts @@ -0,0 +1,42 @@ +import { msg } from '@lingui/core/macro'; + +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; + +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +const WORKSPACE_MEMBER_UPDATE_DISALLOWED_FIELD_NAMES = new Set([ + 'id', + 'userId', +]); + +const WORKSPACE_MEMBER_NON_CUSTOM_UPDATE_FIELD_ALLOWLIST = new Set( + Object.keys(STANDARD_OBJECTS.workspaceMember.fields).filter( + (fieldName) => + !WORKSPACE_MEMBER_UPDATE_DISALLOWED_FIELD_NAMES.has(fieldName), + ), +); + +export const assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly = ({ + update, +}: { + update: Record; +}): void => { + const updateKeys = Object.keys(update); + + if (updateKeys.length === 0) { + throw new UserInputError('Update payload cannot be empty', { + userFriendlyMessage: msg`Add at least one field to update.`, + }); + } + + for (const payloadKey of updateKeys) { + if (!WORKSPACE_MEMBER_NON_CUSTOM_UPDATE_FIELD_ALLOWLIST.has(payloadKey)) { + throw new UserInputError( + `Cannot update custom workspaceMember field via this endpoint: ${payloadKey}`, + { + userFriendlyMessage: msg`"${payloadKey}" is not a valid workspace member field.`, + }, + ); + } + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/services/__tests__/workspace-roles-permissions-cache.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/role/services/__tests__/workspace-roles-permissions-cache.service.spec.ts new file mode 100644 index 0000000000..e2b4610137 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/services/__tests__/workspace-roles-permissions-cache.service.spec.ts @@ -0,0 +1,302 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { PermissionFlagType } from 'twenty-shared/constants'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { type Repository } from 'typeorm'; + +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; +import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; +import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service'; +import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity'; +import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity'; + +const WORKSPACE_ID = '20202020-0000-4000-8000-000000000000'; +const ROLE_ID = '11111111-1111-4111-8111-111111111111'; +const WORKSPACE_MEMBER_OBJECT_METADATA_ID = + '22222222-2222-4222-8222-222222222222'; +const WORKFLOW_OBJECT_METADATA_ID = '33333333-3333-4333-8333-333333333333'; +const PERSON_OBJECT_METADATA_ID = '44444444-4444-4444-8444-444444444444'; + +const createBaseRole = ( + overrides: Partial & + Pick, +): RoleEntity => + ({ + id: ROLE_ID, + label: 'Test role', + workspaceId: WORKSPACE_ID, + canUpdateAllSettings: false, + canAccessAllTools: false, + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + description: null, + icon: null, + isEditable: true, + canBeAssignedToUsers: true, + canBeAssignedToAgents: true, + canBeAssignedToApiKeys: true, + fieldPermissions: [], + rowLevelPermissionPredicates: [], + rowLevelPermissionPredicateGroups: [], + ...overrides, + }) as RoleEntity; + +describe('WorkspaceRolesPermissionsCacheService', () => { + let service: WorkspaceRolesPermissionsCacheService; + let roleRepository: jest.Mocked, 'find'>>; + let objectMetadataRepository: jest.Mocked< + Pick, 'find'> + >; + let objectPermissionRepository: jest.Mocked< + Pick, 'find'> + >; + let permissionFlagRepository: jest.Mocked< + Pick, 'find'> + >; + + const workspaceObjectMetadataFixture: ObjectMetadataEntity[] = [ + { + id: WORKSPACE_MEMBER_OBJECT_METADATA_ID, + isSystem: true, + universalIdentifier: STANDARD_OBJECTS.workspaceMember.universalIdentifier, + labelIdentifierFieldMetadataId: null, + } as ObjectMetadataEntity, + { + id: WORKFLOW_OBJECT_METADATA_ID, + isSystem: true, + universalIdentifier: STANDARD_OBJECTS.workflow.universalIdentifier, + labelIdentifierFieldMetadataId: null, + } as ObjectMetadataEntity, + { + id: PERSON_OBJECT_METADATA_ID, + isSystem: false, + universalIdentifier: STANDARD_OBJECTS.person.universalIdentifier, + labelIdentifierFieldMetadataId: null, + } as ObjectMetadataEntity, + ]; + + beforeEach(async () => { + roleRepository = { + find: jest.fn(), + }; + + objectMetadataRepository = { + find: jest.fn().mockResolvedValue(workspaceObjectMetadataFixture), + }; + + objectPermissionRepository = { + find: jest.fn().mockResolvedValue([]), + }; + + permissionFlagRepository = { + find: jest.fn().mockResolvedValue([]), + }; + const fieldPermissionRepository = { + find: jest.fn().mockResolvedValue([]), + }; + const rowLevelPermissionPredicateRepository = { + find: jest.fn().mockResolvedValue([]), + }; + const rowLevelPermissionPredicateGroupRepository = { + find: jest.fn().mockResolvedValue([]), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceRolesPermissionsCacheService, + { + provide: getRepositoryToken(ObjectMetadataEntity), + useValue: objectMetadataRepository, + }, + { + provide: getRepositoryToken(RoleEntity), + useValue: roleRepository, + }, + { + provide: getRepositoryToken(ObjectPermissionEntity), + useValue: objectPermissionRepository, + }, + { + provide: getRepositoryToken(PermissionFlagEntity), + useValue: permissionFlagRepository, + }, + { + provide: getRepositoryToken(FieldPermissionEntity), + useValue: fieldPermissionRepository, + }, + { + provide: getRepositoryToken(RowLevelPermissionPredicateEntity), + useValue: rowLevelPermissionPredicateRepository, + }, + { + provide: getRepositoryToken(RowLevelPermissionPredicateGroupEntity), + useValue: rowLevelPermissionPredicateGroupRepository, + }, + ], + }).compile(); + + service = module.get(WorkspaceRolesPermissionsCacheService); + }); + + describe('workspaceMember object', () => { + it('should deny all record permissions when role has neither workspace members access nor update-all-settings', async () => { + roleRepository.find.mockResolvedValue([ + createBaseRole({ + permissionFlags: [], + objectPermissions: [], + }), + ]); + + const result = await service.computeForCache(WORKSPACE_ID); + const workspaceMemberPermissions = + result[ROLE_ID][WORKSPACE_MEMBER_OBJECT_METADATA_ID]; + + expect(workspaceMemberPermissions.canReadObjectRecords).toBe(true); + expect(workspaceMemberPermissions.canUpdateObjectRecords).toBe(false); + expect(workspaceMemberPermissions.canSoftDeleteObjectRecords).toBe(false); + expect(workspaceMemberPermissions.canDestroyObjectRecords).toBe(false); + }); + + it('should grant all record permissions when role has WORKSPACE_MEMBERS permission flag', async () => { + permissionFlagRepository.find.mockResolvedValue([ + { + roleId: ROLE_ID, + flag: PermissionFlagType.WORKSPACE_MEMBERS, + } as PermissionFlagEntity, + ]); + + roleRepository.find.mockResolvedValue([ + createBaseRole({ + permissionFlags: [], + objectPermissions: [], + }), + ]); + + const result = await service.computeForCache(WORKSPACE_ID); + const workspaceMemberPermissions = + result[ROLE_ID][WORKSPACE_MEMBER_OBJECT_METADATA_ID]; + + expect(workspaceMemberPermissions.canReadObjectRecords).toBe(true); + expect(workspaceMemberPermissions.canUpdateObjectRecords).toBe(true); + expect(workspaceMemberPermissions.canSoftDeleteObjectRecords).toBe(true); + expect(workspaceMemberPermissions.canDestroyObjectRecords).toBe(true); + }); + + it('should grant all record permissions when role has canUpdateAllSettings', async () => { + roleRepository.find.mockResolvedValue([ + createBaseRole({ + canUpdateAllSettings: true, + permissionFlags: [], + objectPermissions: [], + }), + ]); + + const result = await service.computeForCache(WORKSPACE_ID); + const workspaceMemberPermissions = + result[ROLE_ID][WORKSPACE_MEMBER_OBJECT_METADATA_ID]; + + expect(workspaceMemberPermissions.canReadObjectRecords).toBe(true); + expect(workspaceMemberPermissions.canUpdateObjectRecords).toBe(true); + }); + }); + + describe('workflow object', () => { + it('should deny all record permissions when role has neither workflows access nor update-all-settings', async () => { + roleRepository.find.mockResolvedValue([ + createBaseRole({ + permissionFlags: [], + objectPermissions: [], + }), + ]); + + const result = await service.computeForCache(WORKSPACE_ID); + const workflowPermissions = result[ROLE_ID][WORKFLOW_OBJECT_METADATA_ID]; + + expect(workflowPermissions.canReadObjectRecords).toBe(false); + expect(workflowPermissions.canUpdateObjectRecords).toBe(false); + expect(workflowPermissions.canSoftDeleteObjectRecords).toBe(false); + expect(workflowPermissions.canDestroyObjectRecords).toBe(false); + }); + + it('should grant all record permissions when role has WORKFLOWS permission flag', async () => { + permissionFlagRepository.find.mockResolvedValue([ + { + roleId: ROLE_ID, + flag: PermissionFlagType.WORKFLOWS, + } as PermissionFlagEntity, + ]); + + roleRepository.find.mockResolvedValue([ + createBaseRole({ + permissionFlags: [], + objectPermissions: [], + }), + ]); + + const result = await service.computeForCache(WORKSPACE_ID); + const workflowPermissions = result[ROLE_ID][WORKFLOW_OBJECT_METADATA_ID]; + + expect(workflowPermissions.canReadObjectRecords).toBe(true); + expect(workflowPermissions.canUpdateObjectRecords).toBe(true); + expect(workflowPermissions.canSoftDeleteObjectRecords).toBe(true); + expect(workflowPermissions.canDestroyObjectRecords).toBe(true); + }); + }); + + describe('regular object (person)', () => { + it('should apply object permission overrides when object is not system', async () => { + objectPermissionRepository.find.mockResolvedValue([ + { + roleId: ROLE_ID, + objectMetadataId: PERSON_OBJECT_METADATA_ID, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + } as ObjectPermissionEntity, + ]); + + roleRepository.find.mockResolvedValue([ + createBaseRole({ + permissionFlags: [], + objectPermissions: [], + }), + ]); + + const result = await service.computeForCache(WORKSPACE_ID); + const personPermissions = result[ROLE_ID][PERSON_OBJECT_METADATA_ID]; + + expect(personPermissions.canReadObjectRecords).toBe(true); + expect(personPermissions.canUpdateObjectRecords).toBe(false); + expect(personPermissions.canSoftDeleteObjectRecords).toBe(false); + expect(personPermissions.canDestroyObjectRecords).toBe(false); + }); + + it('should use role-wide CRUD defaults when no object permission row exists', async () => { + roleRepository.find.mockResolvedValue([ + createBaseRole({ + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canSoftDeleteAllObjectRecords: true, + canDestroyAllObjectRecords: true, + permissionFlags: [], + objectPermissions: [], + }), + ]); + + const result = await service.computeForCache(WORKSPACE_ID); + const personPermissions = result[ROLE_ID][PERSON_OBJECT_METADATA_ID]; + + expect(personPermissions.canReadObjectRecords).toBe(true); + expect(personPermissions.canUpdateObjectRecords).toBe(true); + expect(personPermissions.canSoftDeleteObjectRecords).toBe(true); + expect(personPermissions.canDestroyObjectRecords).toBe(true); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service.ts index a0e42b2d57..bdbec04685 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service.ts @@ -28,6 +28,8 @@ const WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS = [ STANDARD_OBJECTS.workflowRun.universalIdentifier, STANDARD_OBJECTS.workflowVersion.universalIdentifier, ] as const; +const WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.workspaceMember.universalIdentifier; @Injectable() @WorkspaceCache('rolesPermissions') @@ -137,47 +139,66 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide let canDestroy = role.canDestroyAllObjectRecords; const restrictedFields: RestrictedFieldsPermissions = {}; - if ( + const isWorkspaceMemberObject = + universalIdentifier === WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER; + const isWorkflowRelatedObject = WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.includes( universalIdentifier as (typeof WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS)[number], - ) - ) { - const hasWorkflowsPermissions = this.hasWorkflowsPermissions( - role, - rolePermissionFlags, ); + if (isWorkflowRelatedObject) { + const hasWorkflowsPermissions = + this.hasSettingsGatedObjectPermissions( + role, + rolePermissionFlags, + PermissionFlagType.WORKFLOWS, + ); + canRead = hasWorkflowsPermissions; canUpdate = hasWorkflowsPermissions; canSoftDelete = hasWorkflowsPermissions; canDestroy = hasWorkflowsPermissions; } else { - const objectRecordPermissionsOverride = roleObjectPermissions.find( - (objectPermission) => - objectPermission.objectMetadataId === objectMetadataId, - ); + if (isWorkspaceMemberObject) { + const hasWorkspaceMembersPermissions = + this.hasSettingsGatedObjectPermissions( + role, + rolePermissionFlags, + PermissionFlagType.WORKSPACE_MEMBERS, + ); - const getPermissionValue = ( - overrideValue: boolean | undefined, - defaultValue: boolean, - ) => (isSystem ? true : (overrideValue ?? defaultValue)); + canRead = true; + canUpdate = hasWorkspaceMembersPermissions; + canSoftDelete = hasWorkspaceMembersPermissions; + canDestroy = hasWorkspaceMembersPermissions; + } else { + const objectRecordPermissionsOverride = roleObjectPermissions.find( + (objectPermission) => + objectPermission.objectMetadataId === objectMetadataId, + ); - canRead = getPermissionValue( - objectRecordPermissionsOverride?.canReadObjectRecords, - canRead, - ); - canUpdate = getPermissionValue( - objectRecordPermissionsOverride?.canUpdateObjectRecords, - canUpdate, - ); - canSoftDelete = getPermissionValue( - objectRecordPermissionsOverride?.canSoftDeleteObjectRecords, - canSoftDelete, - ); - canDestroy = getPermissionValue( - objectRecordPermissionsOverride?.canDestroyObjectRecords, - canDestroy, - ); + const getPermissionValue = ( + overrideValue: boolean | undefined, + defaultValue: boolean, + ) => (isSystem ? true : (overrideValue ?? defaultValue)); + + canRead = getPermissionValue( + objectRecordPermissionsOverride?.canReadObjectRecords, + canRead, + ); + canUpdate = getPermissionValue( + objectRecordPermissionsOverride?.canUpdateObjectRecords, + canUpdate, + ); + canSoftDelete = getPermissionValue( + objectRecordPermissionsOverride?.canSoftDeleteObjectRecords, + canSoftDelete, + ); + canDestroy = getPermissionValue( + objectRecordPermissionsOverride?.canDestroyObjectRecords, + canDestroy, + ); + } const fieldPermissionsForObject = roleFieldPermissions.filter( (fieldPermission) => @@ -246,21 +267,18 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide return workspaceObjectMetadata; } - private hasWorkflowsPermissions( + private hasSettingsGatedObjectPermissions( role: RoleEntity, permissionFlags: PermissionFlagEntity[], + permissionFlagType: PermissionFlagType, ): boolean { - const hasWorkflowsPermissionFromRole = role.canUpdateAllSettings; - const hasWorkflowsPermissionsFromSettingPermissions = isDefined( + const hasPermissionFromRole = role.canUpdateAllSettings; + const hasPermissionFromSettingPermissions = isDefined( permissionFlags.find( - (permissionFlag) => - permissionFlag.flag === PermissionFlagType.WORKFLOWS, + (permissionFlag) => permissionFlag.flag === permissionFlagType, ), ); - return ( - hasWorkflowsPermissionFromRole || - hasWorkflowsPermissionsFromSettingPermissions - ); + return hasPermissionFromRole || hasPermissionFromSettingPermissions; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts index 7b99f773d7..912a02aea8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts @@ -177,6 +177,7 @@ export class ViewQueryParamsService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); const workspaceMember = await workspaceMemberRepository.findOne({ diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts index d100067ec0..36da3fd422 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts @@ -1,5 +1,6 @@ import { isNonEmptyString } from '@sniptt/guards'; import isEmpty from 'lodash.isempty'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { type ObjectsPermissions, type RestrictedFieldsPermissions, @@ -20,6 +21,9 @@ import { } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util'; +const WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.workspaceMember.universalIdentifier; + const getTargetEntityAndOperationType = ( expressionMap: QueryExpressionMap, ): @@ -110,8 +114,11 @@ export const validateOperationIsPermittedOrThrow = ({ } const objectMetadataIsSystem = objectMetadata.isSystem === true; + const isWorkspaceMemberObject = + objectMetadata.universalIdentifier === + WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER; - if (objectMetadataIsSystem) { + if (objectMetadataIsSystem && !isWorkspaceMemberObject) { return; } diff --git a/packages/twenty-server/src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service.ts b/packages/twenty-server/src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service.ts index f7b8d718dd..e44c948a6b 100644 --- a/packages/twenty-server/src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service.ts +++ b/packages/twenty-server/src/modules/blocklist/blocklist-validation-manager/services/blocklist-validation.service.ts @@ -107,6 +107,7 @@ export class BlocklistValidationService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, WorkspaceMemberWorkspaceEntity, + { shouldBypassPermissionChecks: true }, ); return workspaceMemberRepository.findOneByOrFail({ @@ -192,6 +193,7 @@ export class BlocklistValidationService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, WorkspaceMemberWorkspaceEntity, + { shouldBypassPermissionChecks: true }, ); return workspaceMemberRepository.findOneByOrFail({ diff --git a/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-item-delete-calendar-events.job.ts b/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-item-delete-calendar-events.job.ts index 3d19574b04..e9baeec97a 100644 --- a/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-item-delete-calendar-events.job.ts +++ b/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-item-delete-calendar-events.job.ts @@ -1,9 +1,9 @@ import { Scope } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events'; import { isDefined } from 'twenty-shared/utils'; import { And, Any, ILike, In, Not, Or, type Repository } from 'typeorm'; -import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; @@ -87,6 +87,7 @@ export class BlocklistItemDeleteCalendarEventsJob { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); for (const workspaceMemberId of handlesToDeleteByWorkspaceMemberIdMap.keys()) { diff --git a/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-reimport-calendar-events.job.ts b/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-reimport-calendar-events.job.ts index 05b9c16389..4a19eb3b73 100644 --- a/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-reimport-calendar-events.job.ts +++ b/packages/twenty-server/src/modules/calendar/blocklist-manager/jobs/blocklist-reimport-calendar-events.job.ts @@ -1,11 +1,9 @@ import { Scope } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Not, type Repository } from 'typeorm'; import { type ObjectRecordDeleteEvent } from 'twenty-shared/database-events'; +import { Not, type Repository } from 'typeorm'; -import { CalendarChannelSyncStage } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -16,6 +14,8 @@ import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity'; import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service'; +import { CalendarChannelSyncStage } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; export type BlocklistReimportCalendarEventsJobData = WorkspaceEventBatch< ObjectRecordDeleteEvent @@ -46,6 +46,7 @@ export class BlocklistReimportCalendarEventsJob { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); for (const eventPayload of data.events) { diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts index ec82bc12c9..524a5f6db9 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service.ts @@ -4,11 +4,12 @@ import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; import { Any, Repository } from 'typeorm'; -import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; +import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -30,7 +31,6 @@ import { filterEventsAndReturnCancelledEvents } from 'src/modules/calendar/calen import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service'; import { type CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity'; import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event'; -import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { EmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/services/email-alias-manager.service'; import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; @@ -117,6 +117,7 @@ export class CalendarEventsImportService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); const workspaceMember = userWorkspace diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts index 3dd2fd89bc..4ba12f3c4e 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts @@ -14,9 +14,9 @@ import { v4 } from 'uuid'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { CONTACTS_CREATION_BATCH_SIZE } from 'src/modules/contact-creation-manager/constants/contacts-creation-batch-size.constant'; import { CreateCompanyService } from 'src/modules/contact-creation-manager/services/create-company.service'; import { CreatePersonService } from 'src/modules/contact-creation-manager/services/create-person.service'; @@ -70,6 +70,7 @@ export class CreateCompanyAndPersonService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, WorkspaceMemberWorkspaceEntity, + { shouldBypassPermissionChecks: true }, ); const workspaceMembers = await workspaceMemberRepository.find(); @@ -183,6 +184,7 @@ export class CreateCompanyAndPersonService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, WorkspaceMemberWorkspaceEntity, + { shouldBypassPermissionChecks: true }, ); return workspaceMemberRepository.findOne({ diff --git a/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-item-delete-messages.job.ts b/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-item-delete-messages.job.ts index e17c84f86f..b183067ed6 100644 --- a/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-item-delete-messages.job.ts +++ b/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-item-delete-messages.job.ts @@ -92,6 +92,7 @@ export class BlocklistItemDeleteMessagesJob { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); for (const workspaceMemberId of handlesToDeleteByWorkspaceMemberIdMap.keys()) { diff --git a/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-reimport-messages.job.ts b/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-reimport-messages.job.ts index 1ebdf8f0e6..03d6582115 100644 --- a/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-reimport-messages.job.ts +++ b/packages/twenty-server/src/modules/messaging/blocklist-manager/jobs/messaging-blocklist-reimport-messages.job.ts @@ -1,10 +1,9 @@ import { Scope } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Not, Repository } from 'typeorm'; import { type ObjectRecordDeleteEvent } from 'twenty-shared/database-events'; +import { In, Not, Repository } from 'typeorm'; -import { MessageChannelSyncStage } from 'twenty-shared/types'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -17,6 +16,7 @@ import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/typ import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity'; import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { MessageChannelSyncStage } from 'twenty-shared/types'; export type BlocklistReimportMessagesJobData = WorkspaceEventBatch< ObjectRecordDeleteEvent @@ -49,6 +49,7 @@ export class BlocklistReimportMessagesJob { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); for (const eventPayload of data.events) { diff --git a/packages/twenty-server/src/modules/messaging/common/query-hooks/message/apply-messages-visibility-restrictions.service.ts b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/apply-messages-visibility-restrictions.service.ts index 63d8c5f2ee..1b6481d993 100644 --- a/packages/twenty-server/src/modules/messaging/common/query-hooks/message/apply-messages-visibility-restrictions.service.ts +++ b/packages/twenty-server/src/modules/messaging/common/query-hooks/message/apply-messages-visibility-restrictions.service.ts @@ -75,6 +75,7 @@ export class ApplyMessagesVisibilityRestrictionsService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); for (let i = messages.length - 1; i >= 0; i--) { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts index acd4f8d21c..211ada8417 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-messages-import.service.ts @@ -4,25 +4,24 @@ import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; -import { MessageChannelSyncStage } from 'twenty-shared/types'; import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; import { BlocklistRepository } from 'src/modules/blocklist/repositories/blocklist.repository'; import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity'; import { EmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/services/email-alias-manager.service'; -import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; -import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service'; import { MessageImportDriverException, MessageImportDriverExceptionCode, } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service'; import { MessagingGetMessagesService } from 'src/modules/messaging/message-import-manager/services/messaging-get-messages.service'; import { @@ -32,7 +31,8 @@ import { import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service'; import { filterEmails } from 'src/modules/messaging/message-import-manager/utils/filter-emails.util'; import { MessagingMonitoringService } from 'src/modules/messaging/monitoring/services/messaging-monitoring.service'; -import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; +import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { MessageChannelSyncStage } from 'twenty-shared/types'; @Injectable() export class MessagingMessagesImportService { @@ -167,6 +167,7 @@ export class MessagingMessagesImportService { await this.globalWorkspaceOrmManager.getRepository( workspaceId, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); const workspaceMember = userWorkspace diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.post-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.post-query.hook.ts index 7cdfb083db..d959b4e200 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.post-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-delete-one.post-query.hook.ts @@ -75,6 +75,7 @@ export class WorkspaceMemberDeleteOnePostQueryHook await this.globalWorkspaceOrmManager.getRepository( workspace.id, 'workspaceMember', + { shouldBypassPermissionChecks: true }, ); return workspaceMemberRepository.findOne({ diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts index 4ad3d88d93..c7f4857a50 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts @@ -1,22 +1,13 @@ -import { InjectRepository } from '@nestjs/typeorm'; - -import { isDefined } from 'class-validator'; import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; -import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; -import { - AuthException, - AuthExceptionCode, -} from 'src/engine/core-modules/auth/auth.exception'; import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard'; import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; -import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service'; @@ -26,9 +17,7 @@ export class WorkspaceMemberUpdateOnePreQueryHook { constructor( private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService, - @InjectRepository(UserWorkspaceEntity) - private readonly userWorkspaceRepository: Repository, - private readonly coreEntityCacheService: CoreEntityCacheService, + private readonly userWorkspaceService: UserWorkspaceService, ) {} async execute( @@ -45,43 +34,17 @@ export class WorkspaceMemberUpdateOnePreQueryHook userWorkspaceId: isUserAuthContext(authContext) ? authContext.userWorkspaceId : undefined, + workspaceMemberId: isUserAuthContext(authContext) + ? authContext.workspaceMemberId + : undefined, targettedWorkspaceMemberId: payload.id, workspaceId: workspace.id, apiKey: isApiKeyAuthContext(authContext) ? authContext.apiKey : undefined, - workspaceMemberId: isUserAuthContext(authContext) - ? authContext.workspaceMemberId - : undefined, }, ); - // TODO: remove this code once we have migrated locale update to userWorkspace update - if (payload.data.locale && isUserAuthContext(authContext)) { - const userWorkspace = await this.userWorkspaceRepository.findOne({ - where: { - id: authContext.userWorkspaceId, - }, - }); - - if (!isDefined(userWorkspace)) { - throw new AuthException( - 'User workspace not found', - AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, - ); - } - - await this.userWorkspaceRepository.save({ - ...userWorkspace, - locale: payload.data.locale, - }); - - await this.coreEntityCacheService.invalidate( - 'userWorkspaceEntity', - authContext.userWorkspaceId, - ); - } - await this.workspaceMemberPreQueryHookService.completeOnboardingProfileStepIfNameProvided( { userId: isUserAuthContext(authContext) diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-members.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-members.integration-spec.ts index 6ba5b5d40c..c6bf71fbc1 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-members.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-members.integration-spec.ts @@ -1,8 +1,16 @@ +import gql from 'graphql-tag'; import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util'; import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util'; import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { makeMetadataAPIRequestWithMemberRole } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { FieldMetadataType } from 'twenty-shared/types'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant'; @@ -14,7 +22,108 @@ const WORKSPACE_MEMBER_GQL_FIELDS = ` `; describe('workspace members permissions', () => { - it('should allow update when user is updating themself (member role)', async () => { + let customFieldId: string; + let customFieldName: string; + let customRelationFieldId: string; + let customRelationJoinColumnName: string; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + input: { + filter: {}, + paging: { + first: 100, + }, + }, + gqlFields: ` + id + nameSingular + `, + expectToFail: false, + }); + + const workspaceMemberObjectMetadataId = objects.find( + (objectMetadata) => objectMetadata.nameSingular === 'workspaceMember', + )?.id; + const companyObjectMetadataId = objects.find( + (objectMetadata) => objectMetadata.nameSingular === 'company', + )?.id; + + expect(workspaceMemberObjectMetadataId).toBeDefined(); + expect(companyObjectMetadataId).toBeDefined(); + + customFieldName = 'region'; + + const { data } = await createOneFieldMetadata({ + input: { + objectMetadataId: workspaceMemberObjectMetadataId as string, + name: customFieldName, + label: 'In which region the workspace member is based', + type: FieldMetadataType.TEXT, + }, + gqlFields: ` + id + `, + expectToFail: false, + }); + + customFieldId = data.createOneField.id; + + const customRelationFieldName = `workingWithCompany`; + + const { data: customRelationFieldData } = await createOneFieldMetadata({ + input: { + objectMetadataId: workspaceMemberObjectMetadataId as string, + name: customRelationFieldName, + label: 'Company the member is working with', + type: FieldMetadataType.RELATION, + relationCreationPayload: { + targetObjectMetadataId: companyObjectMetadataId as string, + targetFieldLabel: 'workspace members working with the company', + targetFieldIcon: 'IconUsers', + type: RelationType.MANY_TO_ONE, + }, + }, + gqlFields: ` + id + settings + `, + expectToFail: false, + }); + + customRelationFieldId = customRelationFieldData.createOneField.id; + customRelationJoinColumnName = ( + customRelationFieldData.createOneField.settings as Record + ).joinColumnName as string; + + expect(customRelationJoinColumnName).toBeDefined(); + }); + + afterAll(async () => { + if (!customFieldId) { + return; + } + + await deleteOneFieldMetadata({ + input: { + idToDelete: customFieldId, + }, + expectToFail: false, + }); + + if (!customRelationFieldId) { + return; + } + + await deleteOneFieldMetadata({ + input: { + idToDelete: customRelationFieldId, + }, + expectToFail: false, + }); + }); + + it('should deny updateOne on /graphql when member updates themself', async () => { const graphqlOperation = updateOneOperationFactory({ objectMetadataSingularName: 'workspaceMember', gqlFields: WORKSPACE_MEMBER_GQL_FIELDS, @@ -29,26 +138,25 @@ describe('workspace members permissions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expect(response.body.errors).not.toBeDefined(); - expect(response.body.data).toStrictEqual({ - updateWorkspaceMember: { - id: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, - name: { - firstName: 'Jony', - }, - }, - }); - expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); }); - it('should throw when user does not have permission (member role)', async () => { + + it('should deny update on custom field for another workspace member on /graphql (member role)', async () => { + const customFieldValue = 'Ile-de-france'; const graphqlOperation = updateOneOperationFactory({ objectMetadataSingularName: 'workspaceMember', - gqlFields: WORKSPACE_MEMBER_GQL_FIELDS, + gqlFields: ` + id + ${customFieldName} + `, recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM, data: { - name: { - firstName: 'Not Tim', - }, + [customFieldName]: customFieldValue, }, }); @@ -63,6 +171,249 @@ describe('workspace members permissions', () => { expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); }); + it('should throw when member updates a standard field for another workspace member', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'workspaceMember', + gqlFields: WORKSPACE_MEMBER_GQL_FIELDS, + recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM, + data: { + timeZone: 'Europe/Paris', + }, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should throw when payload only contains updatedBy-managed changes', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'workspaceMember', + gqlFields: WORKSPACE_MEMBER_GQL_FIELDS, + recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM, + data: {}, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should allow self update through dedicated metadata mutation', async () => { + const operation = { + query: gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } + `, + variables: { + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + update: { + timeZone: 'Europe/Paris', + }, + }, + }, + }; + + const response = await makeMetadataAPIRequestWithMemberRole(operation); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.updateWorkspaceMemberSettings).toBe(true); + }); + + it('should deny updating another workspace member through dedicated metadata mutation (member role - no workspace member settings permission)', async () => { + const operation = { + query: gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } + `, + variables: { + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM, + update: { + timeZone: 'Europe/Paris', + }, + }, + }, + }; + + const response = await makeMetadataAPIRequestWithMemberRole(operation); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should allow updating another workspace member through dedicated metadata mutation for admin (has workspace member settings permission)', async () => { + const operation = { + query: gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } + `, + variables: { + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM, + update: { + timeZone: 'Europe/London', + }, + }, + }, + }; + + const response = await makeMetadataAPIRequest(operation); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.updateWorkspaceMemberSettings).toBe(true); + }); + + it('should reject custom field updates through dedicated metadata mutation', async () => { + const operation = { + query: gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } + `, + variables: { + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + update: { + [customFieldName]: 'custom-value', + }, + }, + }, + }; + + const response = await makeMetadataAPIRequestWithMemberRole(operation); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + `Cannot update custom workspaceMember field via this endpoint: ${customFieldName}`, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); + + it('should reject custom relation join column updates through dedicated metadata mutation', async () => { + const operation = { + query: gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } + `, + variables: { + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + update: { + [customRelationJoinColumnName]: null, + }, + }, + }, + }; + + const response = await makeMetadataAPIRequestWithMemberRole(operation); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + `Cannot update custom workspaceMember field via this endpoint: ${customRelationJoinColumnName}`, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); + + it('should reject empty update payload through dedicated metadata mutation', async () => { + const operation = { + query: gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } + `, + variables: { + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + update: {}, + }, + }, + }; + + const response = await makeMetadataAPIRequestWithMemberRole(operation); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + 'Update payload cannot be empty', + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); + + it('should reject unknown top-level keys through dedicated metadata mutation (allowlist)', async () => { + const unknownKey = 'notAStandardWorkspaceMemberField'; + + const operation = { + query: gql` + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! + ) { + updateWorkspaceMemberSettings(input: $input) + } + `, + variables: { + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + update: { + [unknownKey]: 'value', + }, + }, + }, + }; + + const response = await makeMetadataAPIRequestWithMemberRole(operation); + + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + `Cannot update custom workspaceMember field via this endpoint: ${unknownKey}`, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); + it('should throw when calling deleteOne ', async () => { const graphqlOperation = deleteOneOperationFactory({ objectMetadataSingularName: 'workspaceMember', diff --git a/packages/twenty-server/test/integration/metadata/suites/minimal-metadata/minimal-metadata-i18n.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/minimal-metadata/minimal-metadata-i18n.integration-spec.ts index 304eb69fde..66179396a8 100644 --- a/packages/twenty-server/test/integration/metadata/suites/minimal-metadata/minimal-metadata-i18n.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/minimal-metadata/minimal-metadata-i18n.integration-spec.ts @@ -28,28 +28,26 @@ type MinimalObjectItem = { const updateWorkspaceMemberLocale = async (locale: string) => { const response = await client - .post('/graphql') + .post('/metadata') .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) .send({ query: ` - mutation UpdateWorkspaceMember( - $workspaceMemberId: UUID! - $data: WorkspaceMemberUpdateInput! + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! ) { - updateWorkspaceMember(id: $workspaceMemberId, data: $data) { - id - locale - } + updateWorkspaceMemberSettings(input: $input) } `, variables: { - workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE, - data: { locale }, + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE, + update: { locale }, + }, }, }); expect(response.body.errors).toBeUndefined(); - expect(response.body.data.updateWorkspaceMember.locale).toBe(locale); + expect(response.body.data.updateWorkspaceMemberSettings).toBe(true); }; const queryMinimalMetadata = () => diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/object-metadata-i18n.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/object-metadata-i18n.integration-spec.ts index 3e1cc7260c..f2c851a2c4 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/object-metadata-i18n.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/object-metadata-i18n.integration-spec.ts @@ -32,28 +32,26 @@ type ObjectNode = { const updateWorkspaceMemberLocale = async (locale: string) => { const response = await client - .post('/graphql') + .post('/metadata') .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) .send({ query: ` - mutation UpdateWorkspaceMember( - $workspaceMemberId: UUID! - $data: WorkspaceMemberUpdateInput! + mutation UpdateWorkspaceMemberSettings( + $input: UpdateWorkspaceMemberSettingsInput! ) { - updateWorkspaceMember(id: $workspaceMemberId, data: $data) { - id - locale - } + updateWorkspaceMemberSettings(input: $input) } `, variables: { - workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE, - data: { locale }, + input: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE, + update: { locale }, + }, }, }); expect(response.body.errors).toBeUndefined(); - expect(response.body.data.updateWorkspaceMember.locale).toBe(locale); + expect(response.body.data.updateWorkspaceMemberSettings).toBe(true); }; const queryMetadataObjects = () =>