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<string, boolean>` 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
<img width="1294" height="287" alt="Screenshot 2026-04-22 at 19 37 40"
src="https://github.com/user-attachments/assets/11ca8635-14d7-40a0-9ca0-76329c54e3c6"
/>

## After
<img width="1283" height="285" alt="Screenshot 2026-04-22 at 19 32 25"
src="https://github.com/user-attachments/assets/5e49fa8a-4541-42ee-96da-4c1de7d00aae"
/>
This commit is contained in:
Weiko
2026-04-24 17:22:44 +02:00
committed by GitHub
parent 6c1c0737b0
commit 2ccc293f99
11 changed files with 233 additions and 5 deletions
@@ -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<string, boolean> = {};
for (const flag of currentUserWorkspace?.permissionFlags ?? []) {
permissionFlags[flag] = true;
}
const targetObjectReadPermissions: Record<string, boolean> = {};
const targetObjectWritePermissions: Record<string, boolean> = {};
@@ -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,
@@ -24,6 +24,7 @@ export const EMPTY_COMMAND_MENU_CONTEXT_API: CommandMenuContextApi = {
},
selectedRecords: [],
featureFlags: {},
permissionFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
@@ -64,6 +64,7 @@ const getWrapper =
},
selectedRecords: [],
featureFlags: {},
permissionFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
@@ -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<string, boolean> = {};
for (const flag of currentUserWorkspace?.permissionFlags ?? []) {
permissionFlags[flag] = true;
}
const targetObjectReadPermissions: Record<string, boolean> = {};
const targetObjectWritePermissions: Record<string, boolean> = {};
@@ -176,6 +185,7 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
objectPermissions,
selectedRecords,
featureFlags,
permissionFlags,
targetObjectReadPermissions,
targetObjectWritePermissions,
objectMetadataItem: objectMetadataItem ?? {},
@@ -36,6 +36,7 @@ const buildMockCommandMenuContextApi = (
},
selectedRecords: [],
featureFlags: {},
permissionFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
@@ -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 {}
@@ -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<string>([
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<void> {
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}`,
);
}
}
@@ -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,
@@ -14,6 +14,7 @@ export type CommandMenuContextApi = {
objectPermissions: ObjectPermissions & { objectMetadataId: string };
selectedRecords: ObjectRecord[];
featureFlags: Record<string, boolean>;
permissionFlags: Record<string, boolean>;
targetObjectReadPermissions: Record<string, boolean>;
targetObjectWritePermissions: Record<string, boolean>;
objectMetadataItem: Record<string, unknown>;
@@ -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);
});
});
});
@@ -24,6 +24,7 @@ const buildContext = (
},
selectedRecords: [],
featureFlags: {},
permissionFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},