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:
@@ -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>;
|
||||
|
||||
+17
-11
@@ -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,
|
||||
|
||||
+22
-24
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+8
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+82
@@ -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);
|
||||
});
|
||||
});
|
||||
+67
@@ -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;
|
||||
};
|
||||
+24
-1
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+148
-129
@@ -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}
|
||||
|
||||
+2
@@ -116,6 +116,8 @@ export const RecordTableCellFieldContextGeneric = ({
|
||||
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
|
||||
isCustom: fieldDefinition.metadata.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
isForbidden: !hasObjectReadPermissions,
|
||||
}}
|
||||
|
||||
+2
@@ -73,6 +73,8 @@ export const RecordTableCellFieldContextLabelIdentifier = ({
|
||||
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
|
||||
isCustom: fieldDefinition.metadata.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
maxWidth: recordField.size,
|
||||
onRecordChipClick: handleChipClick,
|
||||
|
||||
+2
@@ -118,6 +118,8 @@ export const WidgetActionFieldEdit = () => {
|
||||
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
|
||||
isCustom: fieldMetadataItem.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
anchorId: recordFieldInputInstanceId,
|
||||
} satisfies GenericFieldContextType;
|
||||
|
||||
+2
@@ -100,6 +100,8 @@ export const FieldWidgetDisplay = ({
|
||||
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
|
||||
isCustom: fieldMetadataItem.isCustom ?? false,
|
||||
},
|
||||
fieldDefinition,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
}),
|
||||
onMouseEnter: handleMouseEnter,
|
||||
anchorId: getRecordFieldInputInstanceId({
|
||||
|
||||
+11
-7
@@ -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);
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_WORKSPACE_MEMBER_SETTINGS = gql`
|
||||
mutation UpdateWorkspaceMemberSettings(
|
||||
$input: UpdateWorkspaceMemberSettingsInput!
|
||||
) {
|
||||
updateWorkspaceMemberSettings(input: $input)
|
||||
}
|
||||
`;
|
||||
+61
@@ -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,
|
||||
};
|
||||
};
|
||||
+146
@@ -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);
|
||||
});
|
||||
});
|
||||
+171
@@ -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;
|
||||
};
|
||||
+12
-32
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+5
-7
@@ -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);
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ export class UpdateSubscriptionQuantityJob {
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
data.workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workspaceMembersCount = await workspaceMemberRepository.count();
|
||||
|
||||
+5
-6
@@ -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 {}
|
||||
|
||||
+7
@@ -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: {
|
||||
|
||||
+28
@@ -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,
|
||||
|
||||
+17
@@ -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,
|
||||
})
|
||||
|
||||
+85
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+42
@@ -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.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
+302
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+57
-39
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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({
|
||||
|
||||
+2
-1
@@ -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()) {
|
||||
|
||||
+4
-3
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-1
@@ -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({
|
||||
|
||||
+1
@@ -92,6 +92,7 @@ export class BlocklistItemDeleteMessagesJob {
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
for (const workspaceMemberId of handlesToDeleteByWorkspaceMemberIdMap.keys()) {
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
+1
@@ -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--) {
|
||||
|
||||
+6
-5
@@ -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
|
||||
|
||||
+1
@@ -75,6 +75,7 @@ export class WorkspaceMemberDeleteOnePostQueryHook
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
workspace.id,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
return workspaceMemberRepository.findOne({
|
||||
|
||||
+5
-42
@@ -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)
|
||||
|
||||
+367
-16
@@ -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',
|
||||
|
||||
+9
-11
@@ -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 = () =>
|
||||
|
||||
+9
-11
@@ -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 = () =>
|
||||
|
||||
Reference in New Issue
Block a user