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
@@ -6,10 +6,8 @@ import { useDebouncedCallback } from 'use-debounce';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { logError } from '~/utils/logError';
@@ -29,9 +27,7 @@ type NameFieldsProps = {
export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
const { t } = useLingui();
const currentUser = useAtomStateValue(currentUserState);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState(
currentWorkspaceMemberState,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { canEdit: canEditFirstName } = useCanEditProfileField('firstName');
const { canEdit: canEditLastName } = useCanEditProfileField('lastName');
@@ -42,7 +38,7 @@ export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
currentWorkspaceMember?.name?.lastName ?? '',
);
const { updateOneRecord } = useUpdateOneRecord();
const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings();
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = useDebouncedCallback(async () => {
@@ -52,24 +48,15 @@ export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
}
if (autoSave) {
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: currentWorkspaceMember?.id,
updateOneRecordInput: {
await updateWorkspaceMemberSettings({
workspaceMemberId: currentWorkspaceMember.id,
update: {
name: {
firstName: firstName,
lastName: lastName,
},
},
});
setCurrentWorkspaceMember({
...currentWorkspaceMember,
name: {
firstName,
lastName,
},
});
}
} catch (error) {
logError(error);
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const UPDATE_WORKSPACE_MEMBER_SETTINGS = gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`;
@@ -0,0 +1,61 @@
import { useMutation } from '@apollo/client/react';
import {
type CurrentWorkspaceMember,
currentWorkspaceMemberState,
} from '@/auth/states/currentWorkspaceMemberState';
import { UPDATE_WORKSPACE_MEMBER_SETTINGS } from '@/settings/profile/graphql/mutations/updateWorkspaceMemberSettings';
import {
mergeWorkspaceMemberSettingsIntoCurrent,
type WorkspaceMemberSettingsUpdateInput,
} from '@/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
type UpdateWorkspaceMemberSettingsMutationData = {
updateWorkspaceMemberSettings: boolean;
};
export type { WorkspaceMemberSettingsUpdateInput };
export const useUpdateWorkspaceMemberSettings = () => {
const [updateWorkspaceMemberSettingsMutation] =
useMutation<UpdateWorkspaceMemberSettingsMutationData>(
UPDATE_WORKSPACE_MEMBER_SETTINGS,
);
const setCurrentWorkspaceMember = useSetAtomState(
currentWorkspaceMemberState,
);
const updateWorkspaceMemberSettings = async ({
workspaceMemberId,
update,
}: {
workspaceMemberId: string;
update:
| WorkspaceMemberSettingsUpdateInput
| Partial<CurrentWorkspaceMember>
| Record<string, unknown>;
}) => {
const { data } = await updateWorkspaceMemberSettingsMutation({
variables: {
input: {
workspaceMemberId,
update,
},
},
});
if (data?.updateWorkspaceMemberSettings === true) {
setCurrentWorkspaceMember((previous) => {
if (!previous || previous.id !== workspaceMemberId) {
return previous;
}
return mergeWorkspaceMemberSettingsIntoCurrent(previous, update);
});
}
};
return {
updateWorkspaceMemberSettings,
};
};
@@ -0,0 +1,146 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { mergeWorkspaceMemberSettingsIntoCurrent } from '@/settings/profile/utils/mergeWorkspaceMemberSettingsIntoCurrent';
const createBaseWorkspaceMember = (): CurrentWorkspaceMember => ({
id: 'workspace-member-id',
name: {
firstName: 'Jane',
lastName: 'Doe',
},
locale: 'en',
colorScheme: 'System',
userEmail: 'jane@example.com',
avatarUrl: 'https://example.com/avatar.png',
timeZone: 'Europe/London',
dateFormat: null,
timeFormat: null,
numberFormat: null,
calendarStartDay: 1,
});
describe('mergeWorkspaceMemberSettingsIntoCurrent', () => {
it('should not mutate the previous object', () => {
const previous = createBaseWorkspaceMember();
const update = { locale: 'fr-FR' };
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, update);
expect(result).not.toBe(previous);
expect(previous.locale).toBe('en');
expect(result.locale).toBe('fr-FR');
});
it('should merge locale and colorScheme', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
locale: 'de-DE',
colorScheme: 'Dark',
});
expect(result.locale).toBe('de-DE');
expect(result.colorScheme).toBe('Dark');
expect(result.userEmail).toBe(previous.userEmail);
expect(result.id).toBe(previous.id);
});
it('should merge name when payload only includes firstName key', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
name: { firstName: 'Janet' },
} as Record<string, unknown>);
expect(result.name.firstName).toBe('Janet');
expect(result.name.lastName).toBe('Doe');
});
it('should merge name when payload only includes lastName key', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
name: { lastName: 'Smith' },
} as Record<string, unknown>);
expect(result.name.firstName).toBe('Jane');
expect(result.name.lastName).toBe('Smith');
});
it('should merge both name fields when both are non-empty strings', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
name: { firstName: 'A', lastName: 'B' },
});
expect(result.name).toEqual({ firstName: 'A', lastName: 'B' });
});
it('should set avatarUrl to null when update is empty string', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
avatarUrl: '',
});
expect(result.avatarUrl).toBeNull();
});
it('should set avatarUrl to null when update is null', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
avatarUrl: null,
});
expect(result.avatarUrl).toBeNull();
});
it('should set avatarUrl to the new URL when provided', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
avatarUrl: 'https://example.com/new.png',
});
expect(result.avatarUrl).toBe('https://example.com/new.png');
});
it('should merge timeZone and calendarStartDay', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
timeZone: 'America/New_York',
calendarStartDay: 0,
});
expect(result.timeZone).toBe('America/New_York');
expect(result.calendarStartDay).toBe(0);
});
it('should merge format fields from Record payload', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
dateFormat: 'MONTH_FIRST',
timeFormat: 'HOUR_12',
numberFormat: 'COMMAS_AND_DOT',
} as Record<string, unknown>);
expect(result.dateFormat).toBe('MONTH_FIRST');
expect(result.timeFormat).toBe('HOUR_12');
expect(result.numberFormat).toBe('COMMAS_AND_DOT');
});
it('should ignore unknown keys in Record payload', () => {
const previous = createBaseWorkspaceMember();
const result = mergeWorkspaceMemberSettingsIntoCurrent(previous, {
locale: 'es',
unknownField: 'ignored',
} as Record<string, unknown>);
expect(result.locale).toBe('es');
expect('unknownField' in result).toBe(false);
});
});
@@ -0,0 +1,171 @@
import { isNull, isNumber, isString } from '@sniptt/guards';
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
import {
WorkspaceMemberDateFormatEnum,
WorkspaceMemberNumberFormatEnum,
WorkspaceMemberTimeFormatEnum,
} from '~/generated-metadata/graphql';
export type WorkspaceMemberNameUpdate = {
firstName: string;
lastName: string;
};
export type WorkspaceMemberSettingsUpdateInput = {
name?: WorkspaceMemberNameUpdate;
colorScheme?: string;
avatarUrl?: string | null;
locale?: string;
calendarStartDay?: number;
timeZone?: string;
dateFormat?: string;
timeFormat?: string;
numberFormat?: string;
position?: number;
};
const isColorScheme = (value: unknown): value is ColorScheme =>
value === 'Dark' || value === 'Light' || value === 'System';
const WORKSPACE_MEMBER_DATE_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberDateFormatEnum),
);
const isWorkspaceMemberDateFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['dateFormat']> =>
isString(value) && WORKSPACE_MEMBER_DATE_FORMAT_VALUES.has(value);
const WORKSPACE_MEMBER_TIME_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberTimeFormatEnum),
);
const isWorkspaceMemberTimeFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['timeFormat']> =>
isString(value) && WORKSPACE_MEMBER_TIME_FORMAT_VALUES.has(value);
const WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES: ReadonlySet<string> = new Set(
Object.values(WorkspaceMemberNumberFormatEnum),
);
const isWorkspaceMemberNumberFormat = (
value: unknown,
): value is NonNullable<CurrentWorkspaceMember['numberFormat']> =>
isString(value) && WORKSPACE_MEMBER_NUMBER_FORMAT_VALUES.has(value);
export const mergeWorkspaceMemberSettingsIntoCurrent = (
previous: CurrentWorkspaceMember,
update: WorkspaceMemberSettingsUpdateInput | Record<string, unknown>,
): CurrentWorkspaceMember => {
if (!isPlainObject(update)) {
return { ...previous };
}
const payload = update;
let next: CurrentWorkspaceMember = { ...previous };
if ('name' in payload && isPlainObject(payload.name)) {
const namePayload = payload.name;
const firstName = isString(namePayload.firstName)
? namePayload.firstName
: undefined;
const lastName = isString(namePayload.lastName)
? namePayload.lastName
: undefined;
next = {
...next,
name: {
...previous.name,
...(isDefined(firstName) ? { firstName } : {}),
...(isDefined(lastName) ? { lastName } : {}),
},
};
}
if (
'locale' in payload &&
(payload.locale === null || isDefined(payload.locale))
) {
if (isString(payload.locale) || isNull(payload.locale)) {
next = { ...next, locale: payload.locale };
}
}
if ('colorScheme' in payload && isDefined(payload.colorScheme)) {
if (isColorScheme(payload.colorScheme)) {
next = { ...next, colorScheme: payload.colorScheme };
}
}
if ('avatarUrl' in payload) {
const value = payload.avatarUrl;
if (value === '' || isNull(value)) {
next = { ...next, avatarUrl: null };
} else if (isString(value)) {
next = { ...next, avatarUrl: value };
}
}
if (
'timeZone' in payload &&
(isNull(payload.timeZone) || isDefined(payload.timeZone))
) {
if (isString(payload.timeZone) || isNull(payload.timeZone)) {
next = { ...next, timeZone: payload.timeZone };
}
}
if (
'dateFormat' in payload &&
(payload.dateFormat === null || isDefined(payload.dateFormat))
) {
const value = payload.dateFormat;
if (isNull(value)) {
next = { ...next, dateFormat: null };
} else if (isWorkspaceMemberDateFormat(value)) {
next = { ...next, dateFormat: value };
}
}
if (
'timeFormat' in payload &&
(payload.timeFormat === null || isDefined(payload.timeFormat))
) {
const value = payload.timeFormat;
if (isNull(value)) {
next = { ...next, timeFormat: null };
} else if (isWorkspaceMemberTimeFormat(value)) {
next = { ...next, timeFormat: value };
}
}
if (
'numberFormat' in payload &&
(payload.numberFormat === null || isDefined(payload.numberFormat))
) {
const value = payload.numberFormat;
if (isNull(value)) {
next = { ...next, numberFormat: null };
} else if (isWorkspaceMemberNumberFormat(value)) {
next = { ...next, numberFormat: value };
}
}
if (
'calendarStartDay' in payload &&
(isNull(payload.calendarStartDay) || isDefined(payload.calendarStartDay))
) {
const value = payload.calendarStartDay;
if (isNull(value)) {
next = { ...next, calendarStartDay: null };
} else if (isNumber(value) && Number.isFinite(value)) {
next = { ...next, calendarStartDay: value };
}
}
return next;
};
@@ -2,14 +2,13 @@ import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { isDefined } from 'twenty-shared/utils';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import { UploadWorkspaceMemberProfilePictureDocument } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@@ -32,15 +31,13 @@ export const WorkspaceMemberPictureUploader = ({
const [uploadController, setUploadController] =
useState<AbortController | null>(null);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useAtomState(
currentWorkspaceMemberState,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const [uploadPicture] = useMutation(
UploadWorkspaceMemberProfilePictureDocument,
);
const { updateOneRecord } = useUpdateOneRecord();
const { updateWorkspaceMemberSettings } = useUpdateWorkspaceMemberSettings();
const { canEdit: canEditProfilePicture } =
useCanEditProfileField('profilePicture');
@@ -74,20 +71,11 @@ export const WorkspaceMemberPictureUploader = ({
throw new Error('Avatar upload failed');
}
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: workspaceMemberId,
updateOneRecordInput: { avatarUrl: signedFile.url },
});
newAvatarUrl = signedFile.url;
if (isEditingSelf && isDefined(currentWorkspaceMember)) {
setCurrentWorkspaceMember({
...currentWorkspaceMember,
avatarUrl: newAvatarUrl,
});
}
await updateWorkspaceMemberSettings({
workspaceMemberId,
update: { avatarUrl: newAvatarUrl },
});
if (isDefined(onAvatarUpdated)) {
onAvatarUpdated(newAvatarUrl);
@@ -114,19 +102,11 @@ export const WorkspaceMemberPictureUploader = ({
setErrorMessage(null);
try {
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
idToUpdate: workspaceMemberId,
updateOneRecordInput: { avatarUrl: '' },
await updateWorkspaceMemberSettings({
workspaceMemberId,
update: { avatarUrl: '' },
});
if (isEditingSelf && isDefined(currentWorkspaceMember)) {
setCurrentWorkspaceMember({
...currentWorkspaceMember,
avatarUrl: null,
});
}
if (isDefined(onAvatarUpdated)) {
onAvatarUpdated(null);
}