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:
+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
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user