From 2ccc293f998b18c166c9dcd670c1ef5b49d9393e Mon Sep 17 00:00:00 2001 From: Weiko Date: Fri, 24 Apr 2026 17:22:44 +0200 Subject: [PATCH] Gate export/import command menu items by permission flag (#19991) ## Summary - Hides the `exportRecords`, `exportView`, and `importRecords` command menu actions from users whose role does not hold the matching `EXPORT_CSV` / `IMPORT_CSV` permission flag. - Exposes the current user's role permission flags to `conditionalAvailabilityExpression` by adding `permissionFlags: Record` to `CommandMenuContextApi`, mirroring how `featureFlags` is already accessible. - Adds a `2.1.0` workspace upgrade command that rewrites the three existing rows on every active/suspended workspace. ## Before Screenshot 2026-04-22 at 19 37 40 ## After Screenshot 2026-04-22 at 19 32 25 --- .../components/StandalonePageCommandMenu.tsx | 10 ++ .../constants/EmptyCommandMenuContextApi.ts | 1 + .../__tests__/useCloseCommandMenu.test.tsx | 1 + .../hooks/useCommandMenuContextApi.ts | 10 ++ ...nditional-availability-expressions.test.ts | 1 + .../2-1/2-1-upgrade-version-command.module.ts | 10 +- ...d-menu-items-by-permission-flag.command.ts | 141 ++++++++++++++++++ .../standard-command-menu-item.constant.ts | 7 +- .../src/types/CommandMenuContextApi.ts | 1 + ...eConditionalAvailabilityExpression.test.ts | 55 +++++++ ...interpolateCommandMenuItemTemplate.test.ts | 1 + 11 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1790000000000-gate-export-import-command-menu-items-by-permission-flag.command.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx b/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx index b0c5199a32..40d1acb708 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx @@ -1,3 +1,4 @@ +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector'; import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext'; @@ -25,6 +26,7 @@ export const StandalonePageCommandMenu = () => { const isMobile = useIsMobile(); const commandMenuItems = useAtomStateValue(commandMenuItemsSelector); const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const currentUserWorkspace = useAtomStateValue(currentUserWorkspaceState); const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState); const isLayoutCustomizationModeEnabled = useAtomStateValue( isLayoutCustomizationModeEnabledState, @@ -38,6 +40,12 @@ export const StandalonePageCommandMenu = () => { featureFlags[flag.key] = flag.value === true; } + const permissionFlags: Record = {}; + + for (const flag of currentUserWorkspace?.permissionFlags ?? []) { + permissionFlags[flag] = true; + } + const targetObjectReadPermissions: Record = {}; const targetObjectWritePermissions: Record = {}; @@ -74,6 +82,7 @@ export const StandalonePageCommandMenu = () => { }, selectedRecords: [], featureFlags, + permissionFlags, targetObjectReadPermissions, targetObjectWritePermissions, objectMetadataItem: {}, @@ -81,6 +90,7 @@ export const StandalonePageCommandMenu = () => { }; }, [ currentWorkspace?.featureFlags, + currentUserWorkspace?.permissionFlags, isLayoutCustomizationModeEnabled, objectMetadataItems, store, diff --git a/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts index e3f93695cc..04e3eb24b9 100644 --- a/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts @@ -24,6 +24,7 @@ export const EMPTY_COMMAND_MENU_CONTEXT_API: CommandMenuContextApi = { }, selectedRecords: [], featureFlags: {}, + permissionFlags: {}, targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {}, diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx index 097d95b80a..f79235c02c 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx @@ -64,6 +64,7 @@ const getWrapper = }, selectedRecords: [], featureFlags: {}, + permissionFlags: {}, targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {}, diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts index b4da0221ff..4e49638fad 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts @@ -1,3 +1,4 @@ +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; @@ -142,6 +143,14 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { featureFlags[flag.key] = flag.value === true; } + const currentUserWorkspace = useAtomStateValue(currentUserWorkspaceState); + + const permissionFlags: Record = {}; + + for (const flag of currentUserWorkspace?.permissionFlags ?? []) { + permissionFlags[flag] = true; + } + const targetObjectReadPermissions: Record = {}; const targetObjectWritePermissions: Record = {}; @@ -176,6 +185,7 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { objectPermissions, selectedRecords, featureFlags, + permissionFlags, targetObjectReadPermissions, targetObjectWritePermissions, objectMetadataItem: objectMetadataItem ?? {}, diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts b/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts index bfc4765f4e..df982e7521 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts @@ -36,6 +36,7 @@ const buildMockCommandMenuContextApi = ( }, selectedRecords: [], featureFlags: {}, + permissionFlags: {}, targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {}, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts index 847fd44a1a..f5fb87b339 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts @@ -1,18 +1,24 @@ import { Module } from '@nestjs/common'; -import { AddLayoutCustomizationGuardToEditCommandsCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { GateExportImportCommandMenuItemsByPermissionFlagCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1790000000000-gate-export-import-command-menu-items-by-permission-flag.command'; +import { AddLayoutCustomizationGuardToEditCommandsCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @Module({ imports: [ ApplicationModule, + FeatureFlagModule, WorkspaceCacheModule, WorkspaceIteratorModule, WorkspaceMigrationModule, ], - providers: [AddLayoutCustomizationGuardToEditCommandsCommand], + providers: [ + GateExportImportCommandMenuItemsByPermissionFlagCommand, + AddLayoutCustomizationGuardToEditCommandsCommand, + ], }) export class V2_1_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1790000000000-gate-export-import-command-menu-items-by-permission-flag.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1790000000000-gate-export-import-command-menu-items-by-permission-flag.command.ts new file mode 100644 index 0000000000..148f8a247f --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1790000000000-gate-export-import-command-menu-items-by-permission-flag.command.ts @@ -0,0 +1,141 @@ +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant'; +import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +const UNIVERSAL_IDENTIFIERS_TO_FIX = new Set([ + STANDARD_COMMAND_MENU_ITEMS.exportRecords.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.exportView.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.importRecords.universalIdentifier, +]); + +@RegisteredWorkspaceCommand('2.1.0', 1790000000000) +@Command({ + name: 'upgrade:2-1:gate-export-import-by-permission-flag', + description: + 'Gate export/import command menu items (exportRecords, exportView, importRecords) behind EXPORT_CSV / IMPORT_CSV permission flags', +}) +export class GateExportImportCommandMenuItemsByPermissionFlagCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Gating export/import command menu items by permission flag for workspace ${workspaceId}`, + ); + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatCommandMenuItemMaps', + ]); + + const { allFlatEntityMaps: standardAllFlatEntityMaps } = + computeTwentyStandardApplicationAllFlatEntityMaps({ + now: new Date().toISOString(), + workspaceId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + }); + + const itemsToUpdate = [...UNIVERSAL_IDENTIFIERS_TO_FIX] + .map((universalIdentifier) => { + const standardItem = + standardAllFlatEntityMaps.flatCommandMenuItemMaps + .byUniversalIdentifier[universalIdentifier]; + const existingItem = + existingFlatCommandMenuItemMaps.byUniversalIdentifier[ + universalIdentifier + ]; + + if ( + !isDefined(standardItem) || + !isDefined(existingItem) || + existingItem.conditionalAvailabilityExpression === + standardItem.conditionalAvailabilityExpression + ) { + return undefined; + } + + return { + ...existingItem, + conditionalAvailabilityExpression: + standardItem.conditionalAvailabilityExpression, + updatedAt: new Date().toISOString(), + }; + }) + .filter(isDefined); + + if (itemsToUpdate.length === 0) { + this.logger.log( + `Export/import command menu item expressions already up to date for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Found ${itemsToUpdate.length} command menu item(s) to update for workspace ${workspaceId}`, + ); + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would update ${itemsToUpdate.length} command menu item availability expression(s) for workspace ${workspaceId}`, + ); + + return; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: itemsToUpdate, + }, + }, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to update command menu item availability expressions:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + + throw new Error( + `Failed to gate export/import command menu items by permission flag for workspace ${workspaceId}`, + ); + } + + this.logger.log( + `Successfully updated ${itemsToUpdate.length} command menu item availability expression(s) for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts index 921a14591b..c66087e504 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts @@ -148,7 +148,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { position: 10, shortLabel: 'Export', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, - conditionalAvailabilityExpression: null, + conditionalAvailabilityExpression: 'permissionFlags.EXPORT_CSV', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.EXPORT_RECORDS, @@ -192,7 +192,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = { position: 13, shortLabel: 'Import', availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT, - conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView', + conditionalAvailabilityExpression: + 'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.IMPORT_RECORDS, @@ -206,7 +207,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { position: 14, shortLabel: 'Export', availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT, - conditionalAvailabilityExpression: null, + conditionalAvailabilityExpression: 'permissionFlags.EXPORT_CSV', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.EXPORT_VIEW, diff --git a/packages/twenty-shared/src/types/CommandMenuContextApi.ts b/packages/twenty-shared/src/types/CommandMenuContextApi.ts index 1167315545..15c640ccf3 100644 --- a/packages/twenty-shared/src/types/CommandMenuContextApi.ts +++ b/packages/twenty-shared/src/types/CommandMenuContextApi.ts @@ -14,6 +14,7 @@ export type CommandMenuContextApi = { objectPermissions: ObjectPermissions & { objectMetadataId: string }; selectedRecords: ObjectRecord[]; featureFlags: Record; + permissionFlags: Record; targetObjectReadPermissions: Record; targetObjectWritePermissions: Record; objectMetadataItem: Record; diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts index b16baf7efc..63def84683 100644 --- a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts @@ -24,6 +24,7 @@ const buildContext = ( }, selectedRecords: [], featureFlags: {}, + permissionFlags: {}, targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {}, @@ -456,4 +457,58 @@ describe('evaluateConditionalAvailabilityExpression', () => { ).toBe(true); }); }); + + describe('permissionFlags gating', () => { + it('should hide exportRecords when EXPORT_CSV permission flag is missing', () => { + const context = buildContext({ permissionFlags: {} }); + + expect( + evaluateConditionalAvailabilityExpression( + 'permissionFlags.EXPORT_CSV', + context, + ), + ).toBe(false); + }); + + it('should show exportRecords when EXPORT_CSV permission flag is present', () => { + const context = buildContext({ + permissionFlags: { EXPORT_CSV: true }, + }); + + expect( + evaluateConditionalAvailabilityExpression( + 'permissionFlags.EXPORT_CSV', + context, + ), + ).toBe(true); + }); + + it('should hide importRecords when IMPORT_CSV permission flag is missing even if soft-delete filter is off', () => { + const context = buildContext({ + hasAnySoftDeleteFilterOnView: false, + permissionFlags: {}, + }); + + expect( + evaluateConditionalAvailabilityExpression( + 'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV', + context, + ), + ).toBe(false); + }); + + it('should show importRecords when IMPORT_CSV permission flag is present and soft-delete filter is off', () => { + const context = buildContext({ + hasAnySoftDeleteFilterOnView: false, + permissionFlags: { IMPORT_CSV: true }, + }); + + expect( + evaluateConditionalAvailabilityExpression( + 'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV', + context, + ), + ).toBe(true); + }); + }); }); diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts index 514a0e2bce..c7ec84407a 100644 --- a/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts @@ -24,6 +24,7 @@ const buildContext = ( }, selectedRecords: [], featureFlags: {}, + permissionFlags: {}, targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {},