Files
twenty/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetActions.ts
T
Marie bc28e1557c 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.
2026-04-14 16:29:00 +00:00

103 lines
3.4 KiB
TypeScript

import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget';
import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
type UseWidgetActionsParams = {
widget: PageLayoutWidget;
};
export const useWidgetActions = ({
widget,
}: UseWidgetActionsParams): WidgetAction[] => {
const targetRecord = useTargetRecord();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const fieldMetadataId = isFieldWidget(widget)
? widget.configuration.fieldMetadataId
: undefined;
const resolvedFieldMetadataId = useResolveFieldMetadataIdFromNameOrId(
fieldMetadataId ?? '',
);
const { fieldMetadataItem } = useFieldMetadataItemById(
resolvedFieldMetadataId ?? '',
);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const isRecordReadOnly = useIsRecordReadOnly({
recordId: targetRecord.id,
objectMetadataId: objectMetadataItem.id,
});
const actions: WidgetAction[] = [];
if (
!isFieldWidget(widget) ||
!isDefined(fieldMetadataItem) ||
!fieldMetadataItem.isActive
) {
return actions;
}
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: 0,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
const isOneToManyRelation =
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.ONE_TO_MANY;
if (isOneToManyRelation) {
actions.push({
id: 'see-all',
position: 0,
});
}
const isFieldReadOnly = 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,
});
if (!isFieldReadOnly) {
actions.push({
id: 'edit',
position: 1,
});
}
return actions.sort((a, b) => a.position - b.position);
};