From 274f4333b4e8348847dc7d2009067ca1747c5635 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:30:09 +0530 Subject: [PATCH] Field level permission checks on widgets (#14774) --- .../__tests__/checkFieldPermissions.test.ts | 160 +++++++++ .../extractFieldMetadataIdsFromWidget.test.ts | 186 ++++++++++ .../utils/checkFieldPermissions.ts | 21 ++ .../extractFieldMetadataIdsFromWidget.ts | 45 +++ .../PageLayoutWidgetForbiddenDisplay.tsx | 50 +++ .../widgets/components/WidgetRenderer.tsx | 17 +- .../hooks/useWidgetObjectPermissions.ts | 29 -- .../widgets/hooks/useWidgetPermissions.ts | 85 +++++ .../widgets/types/WidgetAccessDenialInfo.ts | 5 + .../page-layout-widget-seeds.constant.ts | 2 + .../get-page-layout-widget-data-seeds.util.ts | 333 ++++++++++++------ .../dev-seeder/services/dev-seeder.service.ts | 1 + 12 files changed, 786 insertions(+), 148 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/checkFieldPermissions.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/extractFieldMetadataIdsFromWidget.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/checkFieldPermissions.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/extractFieldMetadataIdsFromWidget.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay.tsx delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetObjectPermissions.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetPermissions.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAccessDenialInfo.ts diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/checkFieldPermissions.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/checkFieldPermissions.test.ts new file mode 100644 index 00000000000..a3b9a2b9720 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/checkFieldPermissions.test.ts @@ -0,0 +1,160 @@ +import { type ObjectPermissions } from 'twenty-shared/types'; +import { checkFieldPermissions } from '../checkFieldPermissions'; + +describe('checkFieldPermissions', () => { + const mockObjectPermissions: ObjectPermissions = { + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + restrictedFields: {}, + }; + + it('should return true when no fields are restricted', () => { + const fieldIds = ['field-1', 'field-2', 'field-3']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: {}, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should return true when field is not in restrictedFields map', () => { + const fieldIds = ['field-1', 'field-2']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-3': { canRead: false }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should return false when any field has canRead set to false', () => { + const fieldIds = ['field-1', 'field-2', 'field-3']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-2': { canRead: false }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(false); + }); + + it('should return false when multiple fields are restricted', () => { + const fieldIds = ['field-1', 'field-2', 'field-3']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: false }, + 'field-2': { canRead: false }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(false); + }); + + it('should return true when field has canRead set to true', () => { + const fieldIds = ['field-1', 'field-2']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: true }, + 'field-2': { canRead: true }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should return true when field has canRead set to null', () => { + const fieldIds = ['field-1']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: null }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should return true when field has canRead set to undefined', () => { + const fieldIds = ['field-1']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: undefined }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should return true for empty field list', () => { + const fieldIds: string[] = []; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: false }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should handle mixed permissions correctly', () => { + const fieldIds = ['field-1', 'field-2', 'field-3', 'field-4']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: true }, + 'field-2': { canRead: null }, + 'field-3': { canRead: undefined }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should fail fast and return false on first restricted field', () => { + const fieldIds = ['field-1', 'field-2', 'field-3']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: false }, + 'field-2': { canRead: false }, + 'field-3': { canRead: false }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(false); + }); + + it('should only check canRead, not canUpdate', () => { + const fieldIds = ['field-1']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canRead: true, canUpdate: false }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); + + it('should handle field with only canUpdate restriction', () => { + const fieldIds = ['field-1']; + const permissions = { + ...mockObjectPermissions, + restrictedFields: { + 'field-1': { canUpdate: false }, + }, + }; + + expect(checkFieldPermissions(fieldIds, permissions)).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/extractFieldMetadataIdsFromWidget.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/extractFieldMetadataIdsFromWidget.test.ts new file mode 100644 index 00000000000..3b1886e146b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/extractFieldMetadataIdsFromWidget.test.ts @@ -0,0 +1,186 @@ +import { + type PageLayoutWidget, + WidgetType, +} from '~/generated-metadata/graphql'; +import { + AggregateOperations, + GraphOrderBy, + GraphType, +} from '~/generated/graphql'; +import { extractFieldMetadataIdsFromWidget } from '../extractFieldMetadataIdsFromWidget'; + +const createMockWidget = ( + overrides: Partial, +): PageLayoutWidget => ({ + id: 'widget-1', + type: WidgetType.GRAPH, + title: 'Test', + configuration: null, + objectMetadataId: null, + gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 }, + pageLayoutTabId: 'tab-1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + ...overrides, +}); + +describe('extractFieldMetadataIdsFromWidget', () => { + it('should return empty array for IFRAME widget', () => { + const widget = createMockWidget({ + type: WidgetType.IFRAME, + configuration: { + __typename: 'IframeConfiguration' as const, + url: 'https://example.com', + }, + }); + + expect(extractFieldMetadataIdsFromWidget(widget)).toEqual([]); + }); + + it('should return empty array for widget without configuration', () => { + const widget = createMockWidget({ + configuration: null, + }); + + expect(extractFieldMetadataIdsFromWidget(widget)).toEqual([]); + }); + + it('should extract field IDs from BarChartConfiguration', () => { + const widget = createMockWidget({ + configuration: { + __typename: 'BarChartConfiguration' as const, + graphType: GraphType.BAR, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataIdX: 'field-2', + orderByX: GraphOrderBy.FIELD_ASC, + }, + objectMetadataId: 'object-1', + }); + + const result = extractFieldMetadataIdsFromWidget(widget); + + expect(result).toHaveLength(2); + expect(result).toContain('field-1'); + expect(result).toContain('field-2'); + }); + + it('should extract field IDs from BarChartConfiguration with optional Y grouping', () => { + const widget = createMockWidget({ + configuration: { + __typename: 'BarChartConfiguration' as const, + graphType: GraphType.BAR, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataIdX: 'field-2', + groupByFieldMetadataIdY: 'field-3', + orderByX: GraphOrderBy.FIELD_ASC, + }, + objectMetadataId: 'object-1', + }); + + const result = extractFieldMetadataIdsFromWidget(widget); + + expect(result).toHaveLength(3); + expect(result).toContain('field-1'); + expect(result).toContain('field-2'); + expect(result).toContain('field-3'); + }); + + it('should extract field IDs from LineChartConfiguration', () => { + const widget = createMockWidget({ + configuration: { + __typename: 'LineChartConfiguration' as const, + graphType: GraphType.LINE, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.SUM, + groupByFieldMetadataIdX: 'field-2', + orderByX: GraphOrderBy.FIELD_DESC, + }, + objectMetadataId: 'object-1', + }); + + const result = extractFieldMetadataIdsFromWidget(widget); + + expect(result).toHaveLength(2); + expect(result).toContain('field-1'); + expect(result).toContain('field-2'); + }); + + it('should extract field IDs from PieChartConfiguration', () => { + const widget = createMockWidget({ + configuration: { + __typename: 'PieChartConfiguration' as const, + graphType: GraphType.PIE, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataId: 'field-2', + orderBy: GraphOrderBy.FIELD_ASC, + }, + objectMetadataId: 'object-1', + }); + + const result = extractFieldMetadataIdsFromWidget(widget); + + expect(result).toHaveLength(2); + expect(result).toContain('field-1'); + expect(result).toContain('field-2'); + }); + + it('should extract field IDs from NumberChartConfiguration', () => { + const widget = createMockWidget({ + configuration: { + __typename: 'NumberChartConfiguration' as const, + graphType: GraphType.NUMBER, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.AVG, + }, + objectMetadataId: 'object-1', + }); + + const result = extractFieldMetadataIdsFromWidget(widget); + + expect(result).toHaveLength(1); + expect(result).toContain('field-1'); + }); + + it('should extract field IDs from GaugeChartConfiguration', () => { + const widget = createMockWidget({ + configuration: { + __typename: 'GaugeChartConfiguration' as const, + graphType: GraphType.GAUGE, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.SUM, + aggregateFieldMetadataIdTotal: 'field-2', + aggregateOperationTotal: AggregateOperations.COUNT, + }, + objectMetadataId: 'object-1', + }); + + const result = extractFieldMetadataIdsFromWidget(widget); + + expect(result).toHaveLength(2); + expect(result).toContain('field-1'); + expect(result).toContain('field-2'); + }); + + it('should not include undefined optional fields', () => { + const widget = createMockWidget({ + configuration: { + __typename: 'BarChartConfiguration' as const, + graphType: GraphType.BAR, + aggregateFieldMetadataId: 'field-1', + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataIdX: 'field-2', + groupByFieldMetadataIdY: undefined, + orderByX: GraphOrderBy.FIELD_ASC, + }, + objectMetadataId: 'object-1', + }); + + const result = extractFieldMetadataIdsFromWidget(widget); + + expect(result).toHaveLength(2); + expect(result).not.toContain(undefined); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/checkFieldPermissions.ts b/packages/twenty-front/src/modules/page-layout/utils/checkFieldPermissions.ts new file mode 100644 index 00000000000..6d55d9df416 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/checkFieldPermissions.ts @@ -0,0 +1,21 @@ +import { type ObjectPermissions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const checkFieldPermissions = ( + fieldMetadataIds: string[], + objectPermissions: ObjectPermissions, +): boolean => { + const { restrictedFields } = objectPermissions; + + const hasInaccessibleField = fieldMetadataIds.some((fieldId) => { + const fieldPermission = restrictedFields[fieldId]; + + if (!isDefined(fieldPermission)) { + return false; + } + + return fieldPermission.canRead === false; + }); + + return !hasInaccessibleField; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/extractFieldMetadataIdsFromWidget.ts b/packages/twenty-front/src/modules/page-layout/utils/extractFieldMetadataIdsFromWidget.ts new file mode 100644 index 00000000000..e64868e31cd --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/extractFieldMetadataIdsFromWidget.ts @@ -0,0 +1,45 @@ +import { isDefined } from 'twenty-shared/utils'; +import { + type PageLayoutWidget, + WidgetType, +} from '~/generated-metadata/graphql'; + +export const extractFieldMetadataIdsFromWidget = ( + widget: PageLayoutWidget, +): string[] => { + if (widget.type !== WidgetType.GRAPH || !widget.configuration) { + return []; + } + + const config = widget.configuration; + + switch (config.__typename) { + case 'BarChartConfiguration': + case 'LineChartConfiguration': + return [ + config.aggregateFieldMetadataId, + config.groupByFieldMetadataIdX, + ...(isDefined(config.groupByFieldMetadataIdY) + ? [config.groupByFieldMetadataIdY] + : []), + ]; + + case 'PieChartConfiguration': + return [config.aggregateFieldMetadataId, config.groupByFieldMetadataId]; + + case 'NumberChartConfiguration': + return [config.aggregateFieldMetadataId]; + + case 'GaugeChartConfiguration': + return [ + config.aggregateFieldMetadataId, + config.aggregateFieldMetadataIdTotal, + ]; + + case 'IframeConfiguration': + return []; + + default: + return []; + } +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay.tsx new file mode 100644 index 00000000000..d845c13fd1f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay.tsx @@ -0,0 +1,50 @@ +import { type WidgetAccessDenialInfo } from '@/page-layout/widgets/types/WidgetAccessDenialInfo'; +import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { AppTooltip } from 'twenty-ui/display'; + +type PageLayoutWidgetForbiddenDisplayProps = { + widgetId: string; + restriction: WidgetAccessDenialInfo; +}; + +export const PageLayoutWidgetForbiddenDisplay = ({ + widgetId, + restriction, +}: PageLayoutWidgetForbiddenDisplayProps) => { + const tooltipId = `widget-forbidden-tooltip-${widgetId}`; + + const getTooltipContent = () => { + if (restriction.type === 'object' && isDefined(restriction.objectName)) { + const objectName = restriction.objectName; + return t`You do not have permission to access the ${objectName} object`; + } + + if ( + restriction.type === 'field' && + isDefined(restriction.fieldNames) && + restriction.fieldNames.length > 0 + ) { + const fieldsList = restriction.fieldNames.join(', '); + const fieldWord = + restriction.fieldNames.length === 1 ? 'field' : 'fields'; + return t`You do not have permission to access the ${fieldsList} ${fieldWord}`; + } + + return t`You do not have permission to view this widget`; + }; + + return ( + <> +
+ +
+ + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx index b5a5c359f5a..a40b50a8ce2 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx @@ -1,14 +1,14 @@ -import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay'; import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget'; import { useEditPageLayoutWidget } from '@/page-layout/hooks/useEditPageLayoutWidget'; import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; +import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay'; import { WidgetContainer } from '@/page-layout/widgets/components/WidgetContainer'; import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer'; import { WidgetHeader } from '@/page-layout/widgets/components/WidgetHeader'; -import { useWidgetObjectPermissions } from '@/page-layout/widgets/hooks/useWidgetObjectPermissions'; -import { type PageLayoutWidget } from '~/generated/graphql'; +import { useWidgetPermissions } from '@/page-layout/widgets/hooks/useWidgetPermissions'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import styled from '@emotion/styled'; +import { type PageLayoutWidget } from '~/generated/graphql'; type WidgetRendererProps = { widget: PageLayoutWidget; @@ -25,14 +25,14 @@ const StyledContent = styled.div` export const WidgetRenderer = ({ widget }: WidgetRendererProps) => { const { deletePageLayoutWidget } = useDeletePageLayoutWidget(); const { handleEditWidget } = useEditPageLayoutWidget(); - const { haveAccessToWidgetsObject } = useWidgetObjectPermissions(widget); + const { hasAccess, restriction } = useWidgetPermissions(widget); const isPageLayoutInEditMode = useRecoilComponentValue( isPageLayoutInEditModeComponentState, ); return ( - + { onRemove={() => deletePageLayoutWidget(widget.id)} /> - {!haveAccessToWidgetsObject ? ( - + {!hasAccess ? ( + ) : ( )} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetObjectPermissions.ts b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetObjectPermissions.ts deleted file mode 100644 index 34b42e53f77..00000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetObjectPermissions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; -import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { isDefined } from 'twenty-shared/utils'; -import { type PageLayoutWidget } from '~/generated-metadata/graphql'; - -type UseWidgetObjectPermissionsReturn = { - haveAccessToWidgetsObject: boolean; -}; - -export const useWidgetObjectPermissions = ( - widget: PageLayoutWidget, -): UseWidgetObjectPermissionsReturn => { - const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); - - if (!isDefined(widget.objectMetadataId)) { - return { - haveAccessToWidgetsObject: true, - }; - } - - const objectPermissions = getObjectPermissionsForObject( - objectPermissionsByObjectMetadataId, - widget.objectMetadataId, - ); - - return { - haveAccessToWidgetsObject: objectPermissions.canReadObjectRecords, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetPermissions.ts b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetPermissions.ts new file mode 100644 index 00000000000..30b93c46af2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useWidgetPermissions.ts @@ -0,0 +1,85 @@ +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { checkFieldPermissions } from '@/page-layout/utils/checkFieldPermissions'; +import { extractFieldMetadataIdsFromWidget } from '@/page-layout/utils/extractFieldMetadataIdsFromWidget'; +import { type WidgetAccessDenialInfo } from '@/page-layout/widgets/types/WidgetAccessDenialInfo'; +import { isDefined } from 'twenty-shared/utils'; +import { type PageLayoutWidget } from '~/generated-metadata/graphql'; + +export type UseWidgetPermissionsReturn = { + hasAccess: boolean; + restriction: WidgetAccessDenialInfo; +}; + +export const useWidgetPermissions = ( + widget: PageLayoutWidget, +): UseWidgetPermissionsReturn => { + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const { objectMetadataItems } = useObjectMetadataItems(); + + if (!isDefined(widget.objectMetadataId)) { + return { + hasAccess: true, + restriction: { + type: null, + }, + }; + } + + const objectMetadata = objectMetadataItems.find( + (item) => item.id === widget.objectMetadataId, + ); + + const objectPermissions = getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + widget.objectMetadataId, + ); + + const hasObjectAccess = objectPermissions.canReadObjectRecords; + + if (!hasObjectAccess) { + return { + hasAccess: false, + restriction: { + type: 'object', + objectName: objectMetadata?.labelSingular, + }, + }; + } + + const fieldMetadataIds = extractFieldMetadataIdsFromWidget(widget); + const allFieldsAccessible = checkFieldPermissions( + fieldMetadataIds, + objectPermissions, + ); + + if (!allFieldsAccessible) { + const restrictedFieldNames = fieldMetadataIds + .filter((fieldId) => { + const fieldPermission = objectPermissions.restrictedFields[fieldId]; + return isDefined(fieldPermission) && fieldPermission.canRead === false; + }) + .map((fieldId) => { + const field = objectMetadata?.fields?.find((f) => f.id === fieldId); + return field?.label || field?.name || 'Unknown'; + }) + .filter(isDefined); + + return { + hasAccess: false, + restriction: { + type: 'field', + objectName: objectMetadata?.labelSingular, + fieldNames: restrictedFieldNames, + }, + }; + } + + return { + hasAccess: true, + restriction: { + type: null, + }, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAccessDenialInfo.ts b/packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAccessDenialInfo.ts new file mode 100644 index 00000000000..9d1bbcf7d75 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/types/WidgetAccessDenialInfo.ts @@ -0,0 +1,5 @@ +export type WidgetAccessDenialInfo = { + type: 'object' | 'field' | null; + objectName?: string; + fieldNames?: string[]; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/page-layout-widget-seeds.constant.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/page-layout-widget-seeds.constant.ts index 1dd5ab7ad9f..ddeff533618 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/page-layout-widget-seeds.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/constants/page-layout-widget-seeds.constant.ts @@ -12,6 +12,8 @@ export const PAGE_LAYOUT_WIDGET_SEEDS = { CUSTOMER_ANNUAL_RECURRING_REVENUE: 'CUSTOMER_ANNUAL_RECURRING_REVENUE_WIDGET', CUSTOMER_REVENUE_DISTRIBUTION: 'CUSTOMER_REVENUE_DISTRIBUTION_WIDGET', CUSTOMER_AVERAGE_ARR: 'CUSTOMER_AVERAGE_ARR_WIDGET', + CUSTOMER_LINKEDIN_COUNT: 'CUSTOMER_LINKEDIN_COUNT_WIDGET', + CUSTOMER_LINKEDIN_DISTRIBUTION: 'CUSTOMER_LINKEDIN_DISTRIBUTION_WIDGET', TEAM_SIZE: 'TEAM_SIZE_WIDGET', TEAM_GEOGRAPHIC_DISTRIBUTION: 'TEAM_GEOGRAPHIC_DISTRIBUTION_WIDGET', diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-page-layout-widget-data-seeds.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-page-layout-widget-data-seeds.util.ts index a1100edc5ec..8d16b657ce7 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-page-layout-widget-data-seeds.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-page-layout-widget-data-seeds.util.ts @@ -1,15 +1,11 @@ +import { isDefined } from 'twenty-shared/utils'; + import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PAGE_LAYOUT_TAB_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-tab-seeds.constant'; import { PAGE_LAYOUT_WIDGET_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-widget-seeds.constant'; import { generateSeedId } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-seed-id.util'; -import { - BASE_OBJECT_STANDARD_FIELD_IDS, - COMPANY_STANDARD_FIELD_IDS, - OPPORTUNITY_STANDARD_FIELD_IDS, - PERSON_STANDARD_FIELD_IDS, -} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; type PageLayoutWidgetDataSeed = { @@ -27,6 +23,13 @@ type PageLayoutWidgetDataSeed = { objectMetadataId: string | null; }; +const getFieldId = ( + object: ObjectMetadataEntity | undefined, + fieldName: string, +): string | undefined => { + return object?.fields?.find((field) => field.name === fieldName)?.id; +}; + export const getPageLayoutWidgetDataSeeds = ( workspaceId: string, objectMetadataItems: ObjectMetadataEntity[], @@ -47,6 +50,31 @@ export const getPageLayoutWidgetDataSeeds = ( (obj) => obj.nameSingular === 'rocket', ); + const opportunityAmountFieldId = getFieldId(opportunityObject, 'amount'); + const opportunityCloseDateFieldId = getFieldId( + opportunityObject, + 'closeDate', + ); + const opportunityStageFieldId = getFieldId(opportunityObject, 'stage'); + + const companyIdFieldId = getFieldId(companyObject, 'id'); + const companyCreatedAtFieldId = getFieldId(companyObject, 'createdAt'); + const companyEmployeesFieldId = getFieldId(companyObject, 'employees'); + const companyArrFieldId = getFieldId(companyObject, 'annualRecurringRevenue'); + const companyNameFieldId = getFieldId(companyObject, 'name'); + const companyLinkedinLinkFieldId = getFieldId(companyObject, 'linkedinLink'); + + const personIdFieldId = getFieldId(personObject, 'id'); + const personCityFieldId = getFieldId(personObject, 'city'); + const personJobTitleFieldId = getFieldId(personObject, 'jobTitle'); + + const opportunityIdFieldId = getFieldId(opportunityObject, 'id'); + + const taskIdFieldId = getFieldId(taskObject, 'id'); + + const rocketIdFieldId = getFieldId(rocketObject, 'id'); + const rocketCreatedAtFieldId = getFieldId(rocketObject, 'createdAt'); + return [ // Sales Overview Tab Widgets { @@ -61,11 +89,13 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Total Pipeline Value', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 }, - configuration: { - graphType: 'NUMBER', - aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount, - aggregateOperation: AggregateOperations.SUM, - }, + configuration: isDefined(opportunityAmountFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: opportunityAmountFieldId, + aggregateOperation: AggregateOperations.SUM, + } + : null, objectMetadataId: opportunityObject?.id ?? null, }, { @@ -77,16 +107,16 @@ export const getPageLayoutWidgetDataSeeds = ( workspaceId, PAGE_LAYOUT_TAB_SEEDS.SALES_OVERVIEW, ), - title: 'Average Deal Size', + title: 'Rocket Count (Object Permission Test)', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 3, rowSpan: 4, columnSpan: 4 }, - configuration: { - graphType: 'GAUGE', - aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount, - aggregateOperation: AggregateOperations.AVG, - aggregateFieldMetadataIdTotal: OPPORTUNITY_STANDARD_FIELD_IDS.amount, - aggregateOperationTotal: AggregateOperations.MAX, - }, + configuration: isDefined(rocketIdFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: rocketIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + } + : null, objectMetadataId: rocketObject?.id ?? null, }, { @@ -101,13 +131,17 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Revenue Forecast', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 7, rowSpan: 8, columnSpan: 5 }, - configuration: { - graphType: 'LINE', - aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount, - aggregateOperation: AggregateOperations.SUM, - groupByFieldMetadataIdX: OPPORTUNITY_STANDARD_FIELD_IDS.closeDate, - orderByX: 'FIELD_ASC', - }, + configuration: + isDefined(opportunityAmountFieldId) && + isDefined(opportunityCloseDateFieldId) + ? { + graphType: 'LINE', + aggregateFieldMetadataId: opportunityAmountFieldId, + aggregateOperation: AggregateOperations.SUM, + groupByFieldMetadataIdX: opportunityCloseDateFieldId, + orderByX: 'FIELD_ASC', + } + : null, objectMetadataId: opportunityObject?.id ?? null, }, { @@ -122,13 +156,17 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Deals by Stage', type: WidgetType.GRAPH, gridPosition: { row: 4, column: 0, rowSpan: 4, columnSpan: 6 }, - configuration: { - graphType: 'BAR', - aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount, - aggregateOperation: AggregateOperations.SUM, - groupByFieldMetadataIdX: OPPORTUNITY_STANDARD_FIELD_IDS.stage, - orderByX: 'FIELD_DESC', - }, + configuration: + isDefined(opportunityAmountFieldId) && + isDefined(opportunityStageFieldId) + ? { + graphType: 'BAR', + aggregateFieldMetadataId: opportunityAmountFieldId, + aggregateOperation: AggregateOperations.SUM, + groupByFieldMetadataIdX: opportunityStageFieldId, + orderByX: 'FIELD_DESC', + } + : null, objectMetadataId: opportunityObject?.id ?? null, }, @@ -142,16 +180,19 @@ export const getPageLayoutWidgetDataSeeds = ( workspaceId, PAGE_LAYOUT_TAB_SEEDS.SALES_DETAILS, ), - title: 'Deal Distribution', + title: 'Rockets by Created Date (Object Permission Test)', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 0, rowSpan: 5, columnSpan: 5 }, - configuration: { - graphType: 'PIE', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - groupByFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.stage, - orderBy: 'VALUE_DESC', - }, + configuration: + isDefined(rocketIdFieldId) && isDefined(rocketCreatedAtFieldId) + ? { + graphType: 'BAR', + aggregateFieldMetadataId: rocketIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataIdX: rocketCreatedAtFieldId, + orderByX: 'FIELD_ASC', + } + : null, objectMetadataId: rocketObject?.id ?? null, }, { @@ -166,11 +207,13 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Opportunity Count', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 5, rowSpan: 5, columnSpan: 7 }, - configuration: { - graphType: 'NUMBER', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - }, + configuration: isDefined(opportunityIdFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: opportunityIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + } + : null, objectMetadataId: opportunityObject?.id ?? null, }, @@ -187,11 +230,13 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Total Customers', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 }, - configuration: { - graphType: 'NUMBER', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - }, + configuration: isDefined(companyIdFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: companyIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + } + : null, objectMetadataId: companyObject?.id ?? null, }, { @@ -206,13 +251,16 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'New Customers Over Time', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 3, rowSpan: 6, columnSpan: 5 }, - configuration: { - graphType: 'LINE', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - groupByFieldMetadataIdX: BASE_OBJECT_STANDARD_FIELD_IDS.createdAt, - orderByX: 'FIELD_ASC', - }, + configuration: + isDefined(companyIdFieldId) && isDefined(companyCreatedAtFieldId) + ? { + graphType: 'LINE', + aggregateFieldMetadataId: companyIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataIdX: companyCreatedAtFieldId, + orderByX: 'FIELD_ASC', + } + : null, objectMetadataId: companyObject?.id ?? null, }, { @@ -227,13 +275,16 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Companies by Size', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 8, rowSpan: 6, columnSpan: 4 }, - configuration: { - graphType: 'BAR', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - groupByFieldMetadataIdX: COMPANY_STANDARD_FIELD_IDS.employees, - orderByX: 'FIELD_ASC', - }, + configuration: + isDefined(companyIdFieldId) && isDefined(companyEmployeesFieldId) + ? { + graphType: 'BAR', + aggregateFieldMetadataId: companyIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataIdX: companyEmployeesFieldId, + orderByX: 'FIELD_ASC', + } + : null, objectMetadataId: companyObject?.id ?? null, }, @@ -250,12 +301,13 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Annual Recurring Revenue', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, - configuration: { - graphType: 'NUMBER', - aggregateFieldMetadataId: - COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue, - aggregateOperation: AggregateOperations.SUM, - }, + configuration: isDefined(companyArrFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: companyArrFieldId, + aggregateOperation: AggregateOperations.SUM, + } + : null, objectMetadataId: companyObject?.id ?? null, }, { @@ -270,14 +322,16 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Revenue Distribution', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 3 }, - configuration: { - graphType: 'PIE', - aggregateFieldMetadataId: - COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue, - aggregateOperation: AggregateOperations.SUM, - groupByFieldMetadataId: COMPANY_STANDARD_FIELD_IDS.name, - orderBy: 'VALUE_DESC', - }, + configuration: + isDefined(companyArrFieldId) && isDefined(companyNameFieldId) + ? { + graphType: 'PIE', + aggregateFieldMetadataId: companyArrFieldId, + aggregateOperation: AggregateOperations.SUM, + groupByFieldMetadataId: companyNameFieldId, + orderBy: 'VALUE_DESC', + } + : null, objectMetadataId: companyObject?.id ?? null, }, { @@ -292,15 +346,60 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Average ARR', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 7, rowSpan: 6, columnSpan: 5 }, - configuration: { - graphType: 'GAUGE', - aggregateFieldMetadataId: - COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue, - aggregateOperation: AggregateOperations.AVG, - aggregateFieldMetadataIdTotal: - COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue, - aggregateOperationTotal: AggregateOperations.MAX, - }, + configuration: isDefined(companyArrFieldId) + ? { + graphType: 'GAUGE', + aggregateFieldMetadataId: companyArrFieldId, + aggregateOperation: AggregateOperations.AVG, + aggregateFieldMetadataIdTotal: companyArrFieldId, + aggregateOperationTotal: AggregateOperations.MAX, + } + : null, + objectMetadataId: companyObject?.id ?? null, + }, + { + id: generateSeedId( + workspaceId, + PAGE_LAYOUT_WIDGET_SEEDS.CUSTOMER_LINKEDIN_COUNT, + ), + pageLayoutTabId: generateSeedId( + workspaceId, + PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_OVERVIEW, + ), + title: 'LinkedIn Profiles Count (Field Permission Test)', + type: WidgetType.GRAPH, + gridPosition: { row: 2, column: 0, rowSpan: 4, columnSpan: 3 }, + configuration: isDefined(companyLinkedinLinkFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: companyLinkedinLinkFieldId, + aggregateOperation: AggregateOperations.COUNT, + } + : null, + objectMetadataId: companyObject?.id ?? null, + }, + { + id: generateSeedId( + workspaceId, + PAGE_LAYOUT_WIDGET_SEEDS.CUSTOMER_LINKEDIN_DISTRIBUTION, + ), + pageLayoutTabId: generateSeedId( + workspaceId, + PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_OVERVIEW, + ), + title: 'Companies by LinkedIn (Field Permission Test)', + type: WidgetType.GRAPH, + gridPosition: { row: 6, column: 0, rowSpan: 4, columnSpan: 6 }, + configuration: + isDefined(companyIdFieldId) && isDefined(companyLinkedinLinkFieldId) + ? { + graphType: 'PIE', + aggregateFieldMetadataId: companyIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataId: companyLinkedinLinkFieldId, + orderBy: 'VALUE_DESC', + } + : null, objectMetadataId: companyObject?.id ?? null, }, @@ -314,11 +413,13 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Team Size', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 0, rowSpan: 5, columnSpan: 6 }, - configuration: { - graphType: 'NUMBER', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - }, + configuration: isDefined(personIdFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: personIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + } + : null, objectMetadataId: personObject?.id ?? null, }, { @@ -333,13 +434,16 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Geographic Distribution', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 6, rowSpan: 5, columnSpan: 6 }, - configuration: { - graphType: 'BAR', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - groupByFieldMetadataIdX: PERSON_STANDARD_FIELD_IDS.city, - orderByX: 'FIELD_DESC', - }, + configuration: + isDefined(personIdFieldId) && isDefined(personCityFieldId) + ? { + graphType: 'BAR', + aggregateFieldMetadataId: personIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataIdX: personCityFieldId, + orderByX: 'FIELD_DESC', + } + : null, objectMetadataId: personObject?.id ?? null, }, @@ -356,13 +460,16 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Contact Roles', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 6 }, - configuration: { - graphType: 'PIE', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - groupByFieldMetadataId: PERSON_STANDARD_FIELD_IDS.jobTitle, - orderBy: 'VALUE_DESC', - }, + configuration: + isDefined(personIdFieldId) && isDefined(personJobTitleFieldId) + ? { + graphType: 'PIE', + aggregateFieldMetadataId: personIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataId: personJobTitleFieldId, + orderBy: 'VALUE_DESC', + } + : null, objectMetadataId: personObject?.id ?? null, }, { @@ -374,11 +481,13 @@ export const getPageLayoutWidgetDataSeeds = ( title: 'Open Tasks', type: WidgetType.GRAPH, gridPosition: { row: 0, column: 6, rowSpan: 6, columnSpan: 6 }, - configuration: { - graphType: 'NUMBER', - aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id, - aggregateOperation: AggregateOperations.COUNT, - }, + configuration: isDefined(taskIdFieldId) + ? { + graphType: 'NUMBER', + aggregateFieldMetadataId: taskIdFieldId, + aggregateOperation: AggregateOperations.COUNT, + } + : null, objectMetadataId: taskObject?.id ?? null, }, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts index b75fc326f15..b59780427d2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/services/dev-seeder.service.ts @@ -84,6 +84,7 @@ export class DevSeederService { this.coreDataSource.getRepository(ObjectMetadataEntity); const objectMetadataItems = await objectMetadataRepository.find({ where: { workspaceId }, + relations: { fields: true }, }); await seedPageLayoutWidgets(