Files
twenty/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.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

193 lines
6.1 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import {
OrderByDirection,
RecordFilterGroupLogicalOperator,
type RecordGqlOperationFilter,
ViewFilterGroupLogicalOperator,
ViewType,
} from 'twenty-shared/types';
import {
computeRecordGqlOperationFilter,
isDefined,
type RecordFilter,
type RecordFilterGroup,
} from 'twenty-shared/utils';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { DEFAULT_TIMEZONE } from 'src/engine/metadata-modules/view/constants/default-timezone.constant';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export type ViewQueryParams = {
objectNameSingular: string;
filter: RecordGqlOperationFilter;
orderBy: ObjectRecordOrderBy;
viewName: string;
viewType: ViewType;
};
@Injectable()
export class ViewQueryParamsService {
constructor(
private readonly viewService: ViewService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async resolveViewToQueryParams(
viewId: string,
workspaceId: string,
currentWorkspaceMemberId?: string,
): Promise<ViewQueryParams> {
const view = await this.viewService.findByIdWithRelations(
viewId,
workspaceId,
);
if (!view) {
throw new Error(`View with id ${viewId} not found`);
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const objectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: view.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const timeZone = await this.getWorkspaceMemberTimezoneIfAvailable(
workspaceId,
currentWorkspaceMemberId,
);
const recordFilters: RecordFilter[] = (view.viewFilters ?? [])
.map((viewFilter) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewFilter.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!field) return null;
return {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
value: viewFilter.value ?? '',
type: field.type,
recordFilterGroupId: viewFilter.viewFilterGroupId,
operand: viewFilter.operand,
subFieldName: viewFilter.subFieldName,
} as RecordFilter;
})
.filter(isDefined);
const recordFilterGroups: RecordFilterGroup[] = (
view.viewFilterGroups ?? []
).map((group) => ({
id: group.id,
parentRecordFilterGroupId: group.parentViewFilterGroupId,
logicalOperator:
group.logicalOperator === ViewFilterGroupLogicalOperator.OR
? RecordFilterGroupLogicalOperator.OR
: RecordFilterGroupLogicalOperator.AND,
}));
const fields = recordFilters
.map((filter) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: filter.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!field) return null;
return {
id: field.id,
name: field.name,
type: field.type,
label: field.label,
options: field.options?.map((opt) => ({
id: opt.id ?? '',
label: opt.label,
value: opt.value,
color: 'color' in opt ? opt.color : undefined,
position: opt.position,
})),
};
})
.filter(isDefined);
const filter = computeRecordGqlOperationFilter({
fields,
recordFilters,
recordFilterGroups,
filterValueDependencies: { currentWorkspaceMemberId, timeZone },
});
const orderBy: ObjectRecordOrderBy = (view.viewSorts ?? [])
.map((sort) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: sort.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!field) return null;
return {
[field.name]:
sort.direction === ViewSortDirection.DESC
? OrderByDirection.DescNullsLast
: OrderByDirection.AscNullsFirst,
};
})
.filter(isDefined);
return {
objectNameSingular: objectMetadata.nameSingular,
filter,
orderBy,
viewName: view.name,
viewType: view.type,
};
}
private async getWorkspaceMemberTimezoneIfAvailable(
workspaceId: string,
currentWorkspaceMemberId?: string,
): Promise<string> {
if (!isDefined(currentWorkspaceMemberId)) {
return DEFAULT_TIMEZONE;
}
try {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: { id: currentWorkspaceMemberId },
});
return workspaceMember?.timeZone ?? DEFAULT_TIMEZONE;
} catch {
return DEFAULT_TIMEZONE;
}
}
}