Skip to content

Commit 2ccc293

Browse files
authored
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" />
1 parent 6c1c073 commit 2ccc293

11 files changed

Lines changed: 233 additions & 5 deletions

File tree

packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
12
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
23
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
34
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
@@ -25,6 +26,7 @@ export const StandalonePageCommandMenu = () => {
2526
const isMobile = useIsMobile();
2627
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
2728
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
29+
const currentUserWorkspace = useAtomStateValue(currentUserWorkspaceState);
2830
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
2931
const isLayoutCustomizationModeEnabled = useAtomStateValue(
3032
isLayoutCustomizationModeEnabledState,
@@ -38,6 +40,12 @@ export const StandalonePageCommandMenu = () => {
3840
featureFlags[flag.key] = flag.value === true;
3941
}
4042

43+
const permissionFlags: Record<string, boolean> = {};
44+
45+
for (const flag of currentUserWorkspace?.permissionFlags ?? []) {
46+
permissionFlags[flag] = true;
47+
}
48+
4149
const targetObjectReadPermissions: Record<string, boolean> = {};
4250
const targetObjectWritePermissions: Record<string, boolean> = {};
4351

@@ -74,13 +82,15 @@ export const StandalonePageCommandMenu = () => {
7482
},
7583
selectedRecords: [],
7684
featureFlags,
85+
permissionFlags,
7786
targetObjectReadPermissions,
7887
targetObjectWritePermissions,
7988
objectMetadataItem: {},
8089
objectMetadataLabel: '',
8190
};
8291
}, [
8392
currentWorkspace?.featureFlags,
93+
currentUserWorkspace?.permissionFlags,
8494
isLayoutCustomizationModeEnabled,
8595
objectMetadataItems,
8696
store,

packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const EMPTY_COMMAND_MENU_CONTEXT_API: CommandMenuContextApi = {
2424
},
2525
selectedRecords: [],
2626
featureFlags: {},
27+
permissionFlags: {},
2728
targetObjectReadPermissions: {},
2829
targetObjectWritePermissions: {},
2930
objectMetadataItem: {},

packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const getWrapper =
6464
},
6565
selectedRecords: [],
6666
featureFlags: {},
67+
permissionFlags: {},
6768
targetObjectReadPermissions: {},
6869
targetObjectWritePermissions: {},
6970
objectMetadataItem: {},

packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
12
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
23
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
34
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
@@ -142,6 +143,14 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
142143
featureFlags[flag.key] = flag.value === true;
143144
}
144145

146+
const currentUserWorkspace = useAtomStateValue(currentUserWorkspaceState);
147+
148+
const permissionFlags: Record<string, boolean> = {};
149+
150+
for (const flag of currentUserWorkspace?.permissionFlags ?? []) {
151+
permissionFlags[flag] = true;
152+
}
153+
145154
const targetObjectReadPermissions: Record<string, boolean> = {};
146155
const targetObjectWritePermissions: Record<string, boolean> = {};
147156

@@ -176,6 +185,7 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
176185
objectPermissions,
177186
selectedRecords,
178187
featureFlags,
188+
permissionFlags,
179189
targetObjectReadPermissions,
180190
targetObjectWritePermissions,
181191
objectMetadataItem: objectMetadataItem ?? {},

packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const buildMockCommandMenuContextApi = (
3636
},
3737
selectedRecords: [],
3838
featureFlags: {},
39+
permissionFlags: {},
3940
targetObjectReadPermissions: {},
4041
targetObjectWritePermissions: {},
4142
objectMetadataItem: {},
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
import { Module } from '@nestjs/common';
22

3-
import { AddLayoutCustomizationGuardToEditCommandsCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command';
43
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
4+
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';
5+
import { AddLayoutCustomizationGuardToEditCommandsCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command';
56
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
7+
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
68
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
79
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
810

911
@Module({
1012
imports: [
1113
ApplicationModule,
14+
FeatureFlagModule,
1215
WorkspaceCacheModule,
1316
WorkspaceIteratorModule,
1417
WorkspaceMigrationModule,
1518
],
16-
providers: [AddLayoutCustomizationGuardToEditCommandsCommand],
19+
providers: [
20+
GateExportImportCommandMenuItemsByPermissionFlagCommand,
21+
AddLayoutCustomizationGuardToEditCommandsCommand,
22+
],
1723
})
1824
export class V2_1_UpgradeVersionCommandModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Command } from 'nest-commander';
2+
import { isDefined } from 'twenty-shared/utils';
3+
4+
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
5+
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
6+
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
7+
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
8+
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
9+
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
10+
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
11+
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
12+
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
13+
14+
const UNIVERSAL_IDENTIFIERS_TO_FIX = new Set<string>([
15+
STANDARD_COMMAND_MENU_ITEMS.exportRecords.universalIdentifier,
16+
STANDARD_COMMAND_MENU_ITEMS.exportView.universalIdentifier,
17+
STANDARD_COMMAND_MENU_ITEMS.importRecords.universalIdentifier,
18+
]);
19+
20+
@RegisteredWorkspaceCommand('2.1.0', 1790000000000)
21+
@Command({
22+
name: 'upgrade:2-1:gate-export-import-by-permission-flag',
23+
description:
24+
'Gate export/import command menu items (exportRecords, exportView, importRecords) behind EXPORT_CSV / IMPORT_CSV permission flags',
25+
})
26+
export class GateExportImportCommandMenuItemsByPermissionFlagCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
27+
constructor(
28+
protected readonly workspaceIteratorService: WorkspaceIteratorService,
29+
private readonly applicationService: ApplicationService,
30+
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
31+
private readonly workspaceCacheService: WorkspaceCacheService,
32+
) {
33+
super(workspaceIteratorService);
34+
}
35+
36+
override async runOnWorkspace({
37+
workspaceId,
38+
options,
39+
}: RunOnWorkspaceArgs): Promise<void> {
40+
const isDryRun = options.dryRun ?? false;
41+
42+
this.logger.log(
43+
`${isDryRun ? '[DRY RUN] ' : ''}Gating export/import command menu items by permission flag for workspace ${workspaceId}`,
44+
);
45+
46+
const { twentyStandardFlatApplication } =
47+
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
48+
{ workspaceId },
49+
);
50+
51+
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
52+
await this.workspaceCacheService.getOrRecompute(workspaceId, [
53+
'flatCommandMenuItemMaps',
54+
]);
55+
56+
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
57+
computeTwentyStandardApplicationAllFlatEntityMaps({
58+
now: new Date().toISOString(),
59+
workspaceId,
60+
twentyStandardApplicationId: twentyStandardFlatApplication.id,
61+
});
62+
63+
const itemsToUpdate = [...UNIVERSAL_IDENTIFIERS_TO_FIX]
64+
.map((universalIdentifier) => {
65+
const standardItem =
66+
standardAllFlatEntityMaps.flatCommandMenuItemMaps
67+
.byUniversalIdentifier[universalIdentifier];
68+
const existingItem =
69+
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
70+
universalIdentifier
71+
];
72+
73+
if (
74+
!isDefined(standardItem) ||
75+
!isDefined(existingItem) ||
76+
existingItem.conditionalAvailabilityExpression ===
77+
standardItem.conditionalAvailabilityExpression
78+
) {
79+
return undefined;
80+
}
81+
82+
return {
83+
...existingItem,
84+
conditionalAvailabilityExpression:
85+
standardItem.conditionalAvailabilityExpression,
86+
updatedAt: new Date().toISOString(),
87+
};
88+
})
89+
.filter(isDefined);
90+
91+
if (itemsToUpdate.length === 0) {
92+
this.logger.log(
93+
`Export/import command menu item expressions already up to date for workspace ${workspaceId}`,
94+
);
95+
96+
return;
97+
}
98+
99+
this.logger.log(
100+
`Found ${itemsToUpdate.length} command menu item(s) to update for workspace ${workspaceId}`,
101+
);
102+
103+
if (isDryRun) {
104+
this.logger.log(
105+
`[DRY RUN] Would update ${itemsToUpdate.length} command menu item availability expression(s) for workspace ${workspaceId}`,
106+
);
107+
108+
return;
109+
}
110+
111+
const validateAndBuildResult =
112+
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
113+
{
114+
allFlatEntityOperationByMetadataName: {
115+
commandMenuItem: {
116+
flatEntityToCreate: [],
117+
flatEntityToDelete: [],
118+
flatEntityToUpdate: itemsToUpdate,
119+
},
120+
},
121+
workspaceId,
122+
applicationUniversalIdentifier:
123+
twentyStandardFlatApplication.universalIdentifier,
124+
},
125+
);
126+
127+
if (validateAndBuildResult.status === 'fail') {
128+
this.logger.error(
129+
`Failed to update command menu item availability expressions:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
130+
);
131+
132+
throw new Error(
133+
`Failed to gate export/import command menu items by permission flag for workspace ${workspaceId}`,
134+
);
135+
}
136+
137+
this.logger.log(
138+
`Successfully updated ${itemsToUpdate.length} command menu item availability expression(s) for workspace ${workspaceId}`,
139+
);
140+
}
141+
}

packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
148148
position: 10,
149149
shortLabel: 'Export',
150150
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
151-
conditionalAvailabilityExpression: null,
151+
conditionalAvailabilityExpression: 'permissionFlags.EXPORT_CSV',
152152
availabilityObjectMetadataUniversalIdentifier: null,
153153
frontComponentUniversalIdentifier: null,
154154
engineComponentKey: EngineComponentKey.EXPORT_RECORDS,
@@ -192,7 +192,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
192192
position: 13,
193193
shortLabel: 'Import',
194194
availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
195-
conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView',
195+
conditionalAvailabilityExpression:
196+
'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV',
196197
availabilityObjectMetadataUniversalIdentifier: null,
197198
frontComponentUniversalIdentifier: null,
198199
engineComponentKey: EngineComponentKey.IMPORT_RECORDS,
@@ -206,7 +207,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
206207
position: 14,
207208
shortLabel: 'Export',
208209
availabilityType: CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
209-
conditionalAvailabilityExpression: null,
210+
conditionalAvailabilityExpression: 'permissionFlags.EXPORT_CSV',
210211
availabilityObjectMetadataUniversalIdentifier: null,
211212
frontComponentUniversalIdentifier: null,
212213
engineComponentKey: EngineComponentKey.EXPORT_VIEW,

packages/twenty-shared/src/types/CommandMenuContextApi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type CommandMenuContextApi = {
1414
objectPermissions: ObjectPermissions & { objectMetadataId: string };
1515
selectedRecords: ObjectRecord[];
1616
featureFlags: Record<string, boolean>;
17+
permissionFlags: Record<string, boolean>;
1718
targetObjectReadPermissions: Record<string, boolean>;
1819
targetObjectWritePermissions: Record<string, boolean>;
1920
objectMetadataItem: Record<string, unknown>;

packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const buildContext = (
2424
},
2525
selectedRecords: [],
2626
featureFlags: {},
27+
permissionFlags: {},
2728
targetObjectReadPermissions: {},
2829
targetObjectWritePermissions: {},
2930
objectMetadataItem: {},
@@ -456,4 +457,58 @@ describe('evaluateConditionalAvailabilityExpression', () => {
456457
).toBe(true);
457458
});
458459
});
460+
461+
describe('permissionFlags gating', () => {
462+
it('should hide exportRecords when EXPORT_CSV permission flag is missing', () => {
463+
const context = buildContext({ permissionFlags: {} });
464+
465+
expect(
466+
evaluateConditionalAvailabilityExpression(
467+
'permissionFlags.EXPORT_CSV',
468+
context,
469+
),
470+
).toBe(false);
471+
});
472+
473+
it('should show exportRecords when EXPORT_CSV permission flag is present', () => {
474+
const context = buildContext({
475+
permissionFlags: { EXPORT_CSV: true },
476+
});
477+
478+
expect(
479+
evaluateConditionalAvailabilityExpression(
480+
'permissionFlags.EXPORT_CSV',
481+
context,
482+
),
483+
).toBe(true);
484+
});
485+
486+
it('should hide importRecords when IMPORT_CSV permission flag is missing even if soft-delete filter is off', () => {
487+
const context = buildContext({
488+
hasAnySoftDeleteFilterOnView: false,
489+
permissionFlags: {},
490+
});
491+
492+
expect(
493+
evaluateConditionalAvailabilityExpression(
494+
'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV',
495+
context,
496+
),
497+
).toBe(false);
498+
});
499+
500+
it('should show importRecords when IMPORT_CSV permission flag is present and soft-delete filter is off', () => {
501+
const context = buildContext({
502+
hasAnySoftDeleteFilterOnView: false,
503+
permissionFlags: { IMPORT_CSV: true },
504+
});
505+
506+
expect(
507+
evaluateConditionalAvailabilityExpression(
508+
'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV',
509+
context,
510+
),
511+
).toBe(true);
512+
});
513+
});
459514
});

0 commit comments

Comments
 (0)