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