bc28e1557c
## 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.
193 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|