Introduce updateWorkspaceMemberSettings and clarify product (#19441)

## Summary

Introduces a dedicated **metadata** mutation to update **standard
(non-custom)** workspace member settings, moves profile-related UI to
use it, and aligns **workspace member** record permissions with the rest
of the CRM so users cannot escalate visibility via RLS by editing their
own member record.

## Product behaviour

### Profile and appearance (standard fields)

- Users can still update **their own** standard workspace member fields
that the product exposes in **Settings / Profile** (e.g. name, locale,
color scheme, avatar flow) via the new
**`updateWorkspaceMemberSettings`** mutation.
- The mutation returns a **boolean**; the app **merges** the updated
fields into local state so the UI stays in sync without refetching the
full workspace member record.
- **Locale** changes also keep **`userWorkspace`** in sync when a locale
is present in the payload (including from the workspace `updateOne` path
when applicable).

### Custom fields on workspace members

- The dedicated metadata mutation **rejects** any **custom** workspace
member field (and unknown keys). Those updates must go through the
normal **object** `updateOne` pipeline, which is subject to **object-
and field-level** permissions like other records. But since we don't
have object- and field-level permission configuration for system objects
yet, this permission is derived from Workspace member settings
permission.
- **Workspace member** is no longer exempt from ORM permission
validation for updates merely because it is a **system** object. Users
who **do not** have workspace member access (e.g. no **Workspace
members** settings permission and no equivalent broad settings access on
the role) **cannot** use `updateOne` on `workspaceMember` to change
**custom** (or other) fields on their own row—even though that row is
used for RLS predicates.
- This closes a path where someone could widen what they can see by
writing to fields that drive row-level rules.

### Who can change another member

- Updating **another** user’s workspace member still requires
**Workspace members** (or equivalent) settings permission, consistent
with admin tooling.
This commit is contained in:
Marie
2026-04-14 18:29:00 +02:00
committed by GitHub
parent 42f452311b
commit bc28e1557c
58 changed files with 1986 additions and 478 deletions
@@ -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
}
@@ -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)}
@@ -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!"
]
}
@@ -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<Scalars['Boolean']>;
};
export type UpdateWorkspaceMemberSettingsInput = {
update: Scalars['JSON'];
workspaceMemberId: Scalars['UUID'];
};
export type UpsertFieldPermissionsInput = {
fieldPermissions: Array<FieldPermissionInput>;
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<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
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<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
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<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>;
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<UpdateWorkspaceMemberSettingsMutation, UpdateWorkspaceMemberSettingsMutationVariables>;
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<CreateOneRoleMutation, CreateOneRoleMutationVariables>;
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<DeleteOneRoleMutation, DeleteOneRoleMutationVariables>;
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<UpdateOneRoleMutation, UpdateOneRoleMutationVariables>;
@@ -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,
@@ -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 () => {
@@ -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 <K extends FormatPreferenceKey>(
@@ -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,
],
);
@@ -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,
});
};
@@ -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<FieldMetadata>;
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);
});
});
@@ -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<FieldMetadata>;
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;
};
@@ -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<FieldMetadata>;
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
);
};
@@ -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,
@@ -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,
@@ -95,28 +95,87 @@ export const RecordFieldList = ({
>
<PropertyBox dataTestId="record-fields-list-container">
{legacyActivityTargetFieldMetadataItems?.map(
(fieldMetadataItem, index) => (
(fieldMetadataItem, index) => {
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
return (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
onMouseEnter: () => 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,
}),
}}
>
<ActivityTargetsInlineCell
componentInstanceId={getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}
activityObjectNameSingular={
objectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activityRecordId={objectRecordId}
showLabel={true}
maxWidth={200}
/>
</FieldContext.Provider>
);
},
)}
{inlineFieldMetadataItems?.map((fieldMetadataItem, index) => {
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
return (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
onMouseEnter: () => 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,
}),
}}
>
<ActivityTargetsInlineCell
componentInstanceId={getRecordFieldInputInstanceId({
onMouseEnter: () =>
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}
/>
</FieldContext.Provider>
),
)}
{inlineFieldMetadataItems?.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
})}`,
isForbidden: isJunctionRelationForbidden({
fieldMetadataItem,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
}),
}}
>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
onMouseEnter: () =>
handleMouseEnter(
index + (legacyActivityTargetFieldMetadataItems?.length ?? 0),
),
anchorId: `${getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}`,
isForbidden: isJunctionRelationForbidden({
fieldMetadataItem,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
}),
}}
>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId: objectRecordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
}}
>
<RecordInlineCell
loading={recordLoading}
instanceIdPrefix={instanceId}
/>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
))}
}}
>
<RecordInlineCell
loading={recordLoading}
instanceIdPrefix={instanceId}
/>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
);
})}
</PropertyBox>
{showDuplicatesSection && (
<RecordDetailDuplicatesSection
@@ -227,42 +240,48 @@ export const RecordFieldList = ({
fieldMetadataItem.type === FieldMetadataType.RELATION ||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION,
)
.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
}),
}}
>
{fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION ? (
<RecordDetailMorphRelationSection loading={recordLoading} />
) : (
<RecordDetailRelationSection loading={recordLoading} />
)}
</FieldContext.Provider>
))}
.map((fieldMetadataItem, index) => {
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
});
return (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
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,
}),
}}
>
{fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION ? (
<RecordDetailMorphRelationSection loading={recordLoading} />
) : (
<RecordDetailRelationSection loading={recordLoading} />
)}
</FieldContext.Provider>
);
})}
<RecordFieldListCellHoveredPortal
objectMetadataItem={objectMetadataItem}
@@ -116,6 +116,8 @@ export const RecordTableCellFieldContextGeneric = ({
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
isForbidden: !hasObjectReadPermissions,
}}
@@ -73,6 +73,8 @@ export const RecordTableCellFieldContextLabelIdentifier = ({
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
maxWidth: recordField.size,
onRecordChipClick: handleChipClick,
@@ -118,6 +118,8 @@ export const WidgetActionFieldEdit = () => {
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
anchorId: recordFieldInputInstanceId,
} satisfies GenericFieldContextType;
@@ -100,6 +100,8 @@ export const FieldWidgetDisplay = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
onMouseEnter: handleMouseEnter,
anchorId: getRecordFieldInputInstanceId({
@@ -55,6 +55,14 @@ export const FieldsWidgetFieldItem = ({
targetObjectNameSingular,
);
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: globalIndex,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
return (
<FieldContext.Provider
key={recordId + fieldMetadataItem.id}
@@ -62,13 +70,7 @@ export const FieldsWidgetFieldItem = ({
recordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: globalIndex,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
fieldDefinition,
useUpdateRecord,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
@@ -83,6 +85,8 @@ export const FieldsWidgetFieldItem = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
}),
onMouseEnter,
anchorId: `${getRecordFieldInputInstanceId({
@@ -87,6 +87,8 @@ export const useWidgetActions = ({
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
objectPermissionsByObjectMetadataId,
});
if (!isFieldReadOnly) {
@@ -6,10 +6,8 @@ import { useDebouncedCallback } from 'use-debounce';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
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 { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { logError } from '~/utils/logError';
@@ -29,9 +27,7 @@ type NameFieldsProps = {
export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
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);
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPDATE_WORKSPACE_MEMBER_SETTINGS = gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`;
@@ -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<UpdateWorkspaceMemberSettingsMutationData>(
UPDATE_WORKSPACE_MEMBER_SETTINGS,
);
const setCurrentWorkspaceMember = useSetAtomState(
currentWorkspaceMemberState,
);
const updateWorkspaceMemberSettings = async ({
workspaceMemberId,
update,
}: {
workspaceMemberId: string;
update:
| WorkspaceMemberSettingsUpdateInput
| Partial<CurrentWorkspaceMember>
| Record<string, unknown>;
}) => {
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,
};
};
@@ -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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
expect(result.locale).toBe('es');
expect('unknownField' in result).toBe(false);
});
});
@@ -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<string> = new Set(
Object.values(WorkspaceMemberDateFormatEnum),
);
const isWorkspaceMemberDateFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['dateFormat']> =>
isString(value) && WORKSPACE_MEMBER_DATE_FORMAT_VALUES.has(value);
const WORKSPACE_MEMBER_TIME_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberTimeFormatEnum),
);
const isWorkspaceMemberTimeFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['timeFormat']> =>
isString(value) && WORKSPACE_MEMBER_TIME_FORMAT_VALUES.has(value);
const WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberNumberFormatEnum),
);
const isWorkspaceMemberNumberFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['numberFormat']> =>
isString(value) && WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES.has(value);
export const mergeWorkspaceMemberSettingsIntoCurrent = (
previous: CurrentWorkspaceMember,
update: WorkspaceMemberSettingsUpdateInput | Record<string, unknown>,
): 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;
};
@@ -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<AbortController | null>(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);
}
@@ -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,
}),
}));
@@ -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,
],
);
@@ -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,
],
);
@@ -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);
@@ -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';
@@ -34,6 +34,7 @@ export class UpdateSubscriptionQuantityJob {
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
data.workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMembersCount = await workspaceMemberRepository.count();
@@ -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 {}
@@ -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: {
@@ -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<UserWorkspaceEntit
private readonly fileCorePictureService: FileCorePictureService,
private readonly fileUrlService: FileUrlService,
private readonly onboardingService: OnboardingService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {
super(userWorkspaceRepository);
}
async updateUserWorkspaceLocaleForUserWorkspace({
locale,
userWorkspaceId,
}: {
locale: UserWorkspaceEntity['locale'];
userWorkspaceId: string;
}): Promise<void> {
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,
@@ -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<string, unknown>;
}
@@ -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';
@@ -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<boolean> {
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<WorkspaceMemberWorkspaceEntity>(
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<WorkspaceMemberWorkspaceEntity> =
{
id: workspaceMember.id,
...(input.update as Partial<WorkspaceMemberWorkspaceEntity>),
};
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,
})
@@ -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}`,
);
});
});
@@ -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<string>(
Object.keys(STANDARD_OBJECTS.workspaceMember.fields).filter(
(fieldName) =>
!WORKSPACE_MEMBER_UPDATE_DISALLOWED_FIELD_NAMES.has(fieldName),
),
);
export const assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly = ({
update,
}: {
update: Record<string, unknown>;
}): 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.`,
},
);
}
}
};
@@ -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<RoleEntity> &
Pick<RoleEntity, 'permissionFlags' | 'objectPermissions'>,
): 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<Pick<Repository<RoleEntity>, 'find'>>;
let objectMetadataRepository: jest.Mocked<
Pick<Repository<ObjectMetadataEntity>, 'find'>
>;
let objectPermissionRepository: jest.Mocked<
Pick<Repository<ObjectPermissionEntity>, 'find'>
>;
let permissionFlagRepository: jest.Mocked<
Pick<Repository<PermissionFlagEntity>, '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);
});
});
});
@@ -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;
}
}
@@ -177,6 +177,7 @@ export class ViewQueryParamsService {
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = await workspaceMemberRepository.findOne({
@@ -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;
}
@@ -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({
@@ -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()) {
@@ -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<BlocklistWorkspaceEntity>
@@ -46,6 +46,7 @@ export class BlocklistReimportCalendarEventsJob {
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
for (const eventPayload of data.events) {
@@ -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<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = userWorkspace
@@ -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({
@@ -92,6 +92,7 @@ export class BlocklistItemDeleteMessagesJob {
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
for (const workspaceMemberId of handlesToDeleteByWorkspaceMemberIdMap.keys()) {
@@ -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<BlocklistWorkspaceEntity>
@@ -49,6 +49,7 @@ export class BlocklistReimportMessagesJob {
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
for (const eventPayload of data.events) {
@@ -75,6 +75,7 @@ export class ApplyMessagesVisibilityRestrictionsService {
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
for (let i = messages.length - 1; i >= 0; i--) {
@@ -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<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = userWorkspace
@@ -75,6 +75,7 @@ export class WorkspaceMemberDeleteOnePostQueryHook
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspace.id,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
return workspaceMemberRepository.findOne({
@@ -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<UserWorkspaceEntity>,
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)
@@ -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<string, unknown>
).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',
@@ -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 = () =>
@@ -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 = () =>