Introduce updateWorkspaceMemberSettings and clarify product (#19441)

## Summary

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

## Product behaviour

### Profile and appearance (standard fields)

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

### Custom fields on workspace members

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

### Who can change another member

- Updating **another** user’s workspace member still requires
**Workspace members** (or equivalent) settings permission, consistent
with admin tooling.
This commit is contained in:
Marie
2026-04-14 18:29:00 +02:00
committed by GitHub
parent 42f452311b
commit bc28e1557c
58 changed files with 1986 additions and 478 deletions
@@ -1,4 +1,5 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
@@ -39,10 +40,17 @@ export const useIsRecordFieldReadOnly = ({
return false;
}
const fieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
});
return isRecordFieldReadOnly({
isRecordReadOnly,
isSystemObject: objectMetadataItem.isSystem,
objectPermissions,
fieldMetadataItem,
fieldDefinition,
objectPermissionsByObjectMetadataId,
});
};
@@ -0,0 +1,82 @@
import { isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission } from '@/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
const buildObjectPermissionsMap = (opts: {
sourceId: string;
targetId: string;
targetCanUpdate: boolean;
}) => ({
[opts.sourceId]: {
objectMetadataId: opts.sourceId,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
[opts.targetId]: {
objectMetadataId: opts.targetId,
canReadObjectRecords: true,
canUpdateObjectRecords: opts.targetCanUpdate,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
});
describe('isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission', () => {
const sourceObjectMetadataId = 'source-object-id';
const targetObjectMetadataId = 'workspace-member-object-id';
const oneToManyFieldDefinition = {
type: FieldMetadataType.RELATION,
fieldMetadataId: 'field-on-person',
label: 'yehe',
iconName: 'IconRelation',
metadata: {
fieldName: 'yehe',
relationType: RelationType.ONE_TO_MANY,
relationObjectMetadataId: targetObjectMetadataId,
relationObjectMetadataNameSingular: 'workspaceMember',
relationObjectMetadataNamePlural: 'workspaceMembers',
relationFieldMetadataId: 'target-field',
objectMetadataNameSingular: 'person',
targetFieldMetadataName: 'person',
settings: null,
isCustom: true,
isUIReadOnly: false,
},
} as FieldDefinition<FieldMetadata>;
it('should return true when the related object cannot be updated', () => {
expect(
isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({
fieldDefinition: oneToManyFieldDefinition,
objectPermissionsByObjectMetadataId: buildObjectPermissionsMap({
sourceId: sourceObjectMetadataId,
targetId: targetObjectMetadataId,
targetCanUpdate: false,
}),
}),
).toBe(true);
});
it('should return false when the related object can be updated', () => {
expect(
isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({
fieldDefinition: oneToManyFieldDefinition,
objectPermissionsByObjectMetadataId: buildObjectPermissionsMap({
sourceId: sourceObjectMetadataId,
targetId: targetObjectMetadataId,
targetCanUpdate: true,
}),
}),
).toBe(false);
});
});
@@ -0,0 +1,67 @@
import { isNonEmptyString } from '@sniptt/guards';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type ObjectPermissionsByObjectMetadataId = Record<
string,
ObjectPermissions & { objectMetadataId: string }
>;
type IsOneToManyRelationFieldReadOnlyDueToTargetUpdatePermissionParams = {
fieldDefinition: FieldDefinition<FieldMetadata>;
objectPermissionsByObjectMetadataId: ObjectPermissionsByObjectMetadataId;
};
// One-to-many edits persist by updating the related (or junction) record, not the
// source row — require canUpdate on that object metadata.
export const isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission = ({
fieldDefinition,
objectPermissionsByObjectMetadataId,
}: IsOneToManyRelationFieldReadOnlyDueToTargetUpdatePermissionParams): boolean => {
if (isFieldRelationOneToMany(fieldDefinition)) {
const relationObjectMetadataId =
fieldDefinition.metadata.relationObjectMetadataId;
if (!isNonEmptyString(relationObjectMetadataId)) {
return false;
}
const relationObjectPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
relationObjectMetadataId,
);
return relationObjectPermissions.canUpdateObjectRecords === false;
}
if (isFieldMorphRelationOneToMany(fieldDefinition)) {
const morphTargetIds = [
...new Set(
fieldDefinition.metadata.morphRelations.map(
(relation) => relation.targetObjectMetadata.id,
),
),
];
if (!isDefined(morphTargetIds[0])) {
return false;
}
return morphTargetIds.every((targetObjectMetadataId) => {
const targetPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
targetObjectMetadataId,
);
return targetPermissions.canUpdateObjectRecords === false;
});
}
return false;
};
@@ -1,6 +1,16 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
import { isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission } from '@/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type ObjectPermission } from '~/generated-metadata/graphql';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type ObjectPermissionsByObjectMetadataId = Record<
string,
ObjectPermissions & { objectMetadataId: string }
>;
type IsRecordFieldReadOnlyParams = {
isRecordReadOnly: boolean;
@@ -10,6 +20,8 @@ type IsRecordFieldReadOnlyParams = {
'id' | 'isUIReadOnly' | 'isCustom'
>;
objectPermissions: ObjectPermission;
fieldDefinition?: FieldDefinition<FieldMetadata>;
objectPermissionsByObjectMetadataId?: ObjectPermissionsByObjectMetadataId;
};
export const isRecordFieldReadOnly = ({
@@ -17,16 +29,27 @@ export const isRecordFieldReadOnly = ({
isRecordReadOnly,
isSystemObject,
fieldMetadataItem,
fieldDefinition,
objectPermissionsByObjectMetadataId,
}: IsRecordFieldReadOnlyParams) => {
const fieldReadOnlyByPermissions = isFieldMetadataReadOnlyByPermissions({
objectPermissions,
fieldMetadataId: fieldMetadataItem.id,
});
const oneToManyTargetReadOnly =
isDefined(fieldDefinition) &&
isDefined(objectPermissionsByObjectMetadataId) &&
isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission({
fieldDefinition,
objectPermissionsByObjectMetadataId,
});
return (
isRecordReadOnly ||
(isSystemObject === true && fieldMetadataItem.isCustom !== true) ||
fieldMetadataItem.isUIReadOnly ||
fieldReadOnlyByPermissions
fieldReadOnlyByPermissions ||
oneToManyTargetReadOnly
);
};