From 3bdae5445eb8bef775df695270772775f4212d09 Mon Sep 17 00:00:00 2001 From: Madhavi Gayathri Date: Thu, 9 Jul 2026 15:33:58 +0530 Subject: [PATCH 1/4] Light up shared-scope rows together across granular and legacy modes. --- .../console-role-permissions.tsx | 36 +-- ...e-console-role-wizard-permissions-form.tsx | 243 ++++++++++++++---- .../utils/get-eligibility-scope-names.ts | 5 +- 3 files changed, 208 insertions(+), 76 deletions(-) diff --git a/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx b/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx index 466bfb7a6f7..f0fcab6c21f 100644 --- a/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx +++ b/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx @@ -241,21 +241,18 @@ const ConsoleRolePermissions: FunctionComponent = ( * Checks whether every scope in `categories` appears in `selectedFeatures`. * An empty (or absent) category is treated as "not selected". * - * In granular mode (`useEligibilityScopes`), the per-action feature scopes - * (`console:_create/_update/_delete`) are dropped from the check so a level backed - * by the same management scope as another (e.g. Branding's create/update/delete, all resolving - * to `internal_branding_preference_update`) is reported as selected whenever that shared scope - * is granted — keeping the loaded role's checkbox state consistent with the live form. The - * legacy read/write path keeps evaluating the raw scope names. + * The per-action feature scopes (`console:_create/_update/_delete/_edit`) are dropped + * from the check in both modes — only the underlying management scopes (the `internal_*` scopes + * that actually grant the capability) decide whether a box is reported as selected. This keeps + * the loaded role's checkbox state consistent with the live form, and lights up boxes whose + * required capability is already granted through a shared scope even if the per-action feature + * scope itself is not persisted on the role. */ const allScopesSelected = ( categories: APIResourceCollectionPermissionCategoryInterface[] | undefined, - selectedFeatures: string[], - useEligibilityScopes: boolean = false + selectedFeatures: string[] ): boolean => { - const names: string[] = useEligibilityScopes - ? getEligibilityScopeNames(extractScopeNames(categories)) - : extractScopeNames(categories); + const names: string[] = getEligibilityScopeNames(extractScopeNames(categories)); return names.length > 0 && names.every((name: string) => selectedFeatures.includes(name)); }; @@ -273,6 +270,12 @@ const ConsoleRolePermissions: FunctionComponent = ( * Each flag is set to `true` only when all of its scopes are present in the role. * Any combination of flags is valid; there is no precedence between them. * + * In both modes the eligibility check ignores the per-action feature scopes + * (`console:_create/_update/_delete/_edit`) and decides each level by the underlying + * management scopes (the `internal_*` scopes that actually grant the capability). This keeps + * boxes lit when a role holds the management scope but not the cosmetic feature scope, and + * lets levels backed by the same management scope load together. + * * @param collection - API resource collection. * @param selectedFeatures - Scope names already assigned to the role. * @returns Populated SelectedPermissionCategoryInterface, or null if nothing is selected. @@ -282,22 +285,19 @@ const ConsoleRolePermissions: FunctionComponent = ( selectedFeatures: string[] ): SelectedPermissionCategoryInterface | null => { if (useGranularConsolePermissions) { - // Granular mode. Evaluate eligibility on the management scopes, ignoring the per-action - // feature scopes, so levels resolving to the same management scope load together. - // // A create / update / delete level that carries no action scope of its own (only its // per-action feature scope — e.g. Approvals) is not actionable: its cell is read-only in // the form, so it must never be reported as selected here. Gate each write level on // `isPermissionLevelActionable` before testing its scopes, otherwise — because the bucket // is a cumulative superset of read and the feature scope is stripped by the eligibility // check — it would falsely light up whenever read is granted. `read` is always actionable. - const hasRead: boolean = allScopesSelected(collection.apiResources.read, selectedFeatures, true); + const hasRead: boolean = allScopesSelected(collection.apiResources.read, selectedFeatures); const hasCreate: boolean = isPermissionLevelActionable(collection, "create") - && allScopesSelected(collection.apiResources.create, selectedFeatures, true); + && allScopesSelected(collection.apiResources.create, selectedFeatures); const hasUpdate: boolean = isPermissionLevelActionable(collection, "update") - && allScopesSelected(collection.apiResources.update, selectedFeatures, true); + && allScopesSelected(collection.apiResources.update, selectedFeatures); const hasDelete: boolean = isPermissionLevelActionable(collection, "delete") - && allScopesSelected(collection.apiResources.delete, selectedFeatures, true); + && allScopesSelected(collection.apiResources.delete, selectedFeatures); if (!hasRead && !hasCreate && !hasUpdate && !hasDelete) { return null; diff --git a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx index a8b8a6c795c..8895140c5cd 100644 --- a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx +++ b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx @@ -104,6 +104,7 @@ const TENANT_PERMISSIONS_ACCORDION_ID: string = "tenant-permissions"; const ORGANIZATION_PERMISSIONS_ACCORDION_ID: string = "organization-permissions"; type GranularPermissionLevel = "read" | "create" | "update" | "delete"; +type LegacyPermissionLevel = "read" | "write"; const GRANULAR_PERMISSION_COLUMNS: ReadonlyArray = [ "read", @@ -636,37 +637,181 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent + collection?.apiResources?.[level] + ? transformResourceCollectionToPermissions(collection.apiResources[level]) + .map((scope: PermissionScopeInterface) => scope.value) + : []; + + /** + * Legacy counterpart of {@link deriveSelectedFromScopes}: for every collection, checks whether + * write's (or, failing that, read's) eligibility scopes are all present in `grantedScopeNames`, + * and lights up the row accordingly. Write wins when both are covered — same precedence as the + * old `handlePermissionLevelChange` used to enforce directly. + * + * Because the eligibility check strips the per-action feature scopes, two collections backed by + * the same underlying management scope light up together: selecting one row's read (or write) + * now marks every other row whose eligibility scopes the change made covered. + */ + const deriveLegacySelectedFromScopes = (grantedScopeNames: Set): SelectedPermissionsInterface => { + const result: SelectedPermissionsInterface = { organization: {}, tenant: {} }; + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const readEligibility: string[] = + getEligibilityScopeNames(getLegacyLevelScopeNames(collection, "read")); + const writeEligibility: string[] = + getEligibilityScopeNames(getLegacyLevelScopeNames(collection, "write")); + + const readCovered: boolean = readEligibility.length > 0 + && readEligibility.every( + (name: string) => grantedScopeNames.has(qualifyScope(type, name))); + const writeCovered: boolean = writeEligibility.length > 0 + && writeEligibility.every( + (name: string) => grantedScopeNames.has(qualifyScope(type, name))); + + if (writeCovered) { + result[type][collection.id] = { + permissions: transformResourceCollectionToPermissions( + collection.apiResources.write ?? collection.apiResources.read + ), + read: false, + write: true + }; + } else if (readCovered) { + result[type][collection.id] = { + permissions: transformResourceCollectionToPermissions(collection.apiResources.read), + read: true, + write: false + }; + } + }); + } + ); + + return result; + }; + + /** + * Legacy counterpart of {@link applyGranularSelectionChange}. Derives the current row state from + * the granted scopes, lets the caller flip one or more rows on that draft, then: + * 1. rebuilds the scope set from the post-toggle boxes — shared scopes stay alive as long as + * any still-checked row carries them, so unrelated rows are undisturbed; + * 2. force-removes the marginal scopes of any row switched off (or downgraded from write→read), + * so a management scope shared across rows is dropped rather than kept alive by a sibling + * that is still checked; + * 3. re-derives the full table from the resulting scopes, lighting up every row a shared scope + * now covers (this is what makes "one row selects other relevant rows too" work); + * 4. tightens the stored scope set down to exactly what the re-derived table backs, so persisted + * scopes never drift from what the UI shows. + */ + const applyLegacySelectionChange = (mutate: (draft: SelectedPermissionsInterface) => void): void => { + const draft: SelectedPermissionsInterface = deriveLegacySelectedFromScopes(grantedScopes); + const before: SelectedPermissionsInterface = cloneDeep(draft); + + mutate(draft); + + // Collect scopes to force-remove for rows that were unchecked or downgraded from write→read. + const scopesToRemove: Set = new Set(); + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const wasEntry: SelectedPermissionCategoryInterface | undefined = + before[type][collection.id]; + const nextEntry: SelectedPermissionCategoryInterface | undefined = + draft[type][collection.id]; + + const wasRead: boolean = !!wasEntry?.read; + const wasWrite: boolean = !!wasEntry?.write; + const isRead: boolean = !!nextEntry?.read; + const isWrite: boolean = !!nextEntry?.write; + + // Downgraded from write to read (or unchecked) — drop write-only scopes. + if (wasWrite && !isWrite) { + const readNames: Set = + new Set(getLegacyLevelScopeNames(collection, "read")); + const marginalWriteNames: string[] = + getLegacyLevelScopeNames(collection, "write") + .filter((name: string) => !readNames.has(name)); + + marginalWriteNames.forEach( + (name: string) => scopesToRemove.add(qualifyScope(type, name))); + } + + // Row unchecked entirely — drop read scopes too. + if ((wasRead || wasWrite) && !isRead && !isWrite) { + getLegacyLevelScopeNames(collection, "read").forEach( + (name: string) => scopesToRemove.add(qualifyScope(type, name))); + } + }); + } + ); + + // Rebuild the granted scope set from the post-toggle boxes. + const intendedScopes: Set = new Set(); + + [ APIResourceCollectionTypes.TENANT, APIResourceCollectionTypes.ORGANIZATION ].forEach( + (type: APIResourceCollectionTypes) => { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + const entry: SelectedPermissionCategoryInterface | undefined = draft[type][collection.id]; + + if (!entry) { + return; + } + + const level: LegacyPermissionLevel | null = + entry.write ? "write" : entry.read ? "read" : null; + + if (level === null) { + return; + } + + getLegacyLevelScopeNames(collection, level).forEach( + (name: string) => intendedScopes.add(qualifyScope(type, name))); + }); + } + ); + + scopesToRemove.forEach((scope: string) => intendedScopes.delete(scope)); + + const nextSelected: SelectedPermissionsInterface = deriveLegacySelectedFromScopes(intendedScopes); + const tightenedScopes: Set = deriveScopesFromSelection(nextSelected); + + setGrantedScopes(tightenedScopes); + setSelectedPermissions(nextSelected); + processPermissionsChange(nextSelected); + }; + + /** + * Handles the accordion-level select-all checkbox change — flips every row on (as read) or off. * * Legacy mode only; the accordion select-all checkbox is not rendered in granular mode. */ const handleSelectAll = (e: ChangeEvent, type: APIResourceCollectionTypes): void => { - const _selectedPermissions: SelectedPermissionsInterface = cloneDeep(selectedPermissions); - const sourceCollections: APIResourceCollectionInterface[] = getSourceCollections(type); + const checked: boolean = e.target.checked; - if (e.target.checked) { - _selectedPermissions[type] = sourceCollections.reduce( - ( - result: { [key: string]: SelectedPermissionCategoryInterface }, - collection: APIResourceCollectionInterface - ) => { - result[collection.id] = { - permissions: transformResourceCollectionToPermissions(collection.apiResources.read), + applyLegacySelectionChange((draft: SelectedPermissionsInterface) => { + if (checked) { + getSourceCollections(type).forEach((collection: APIResourceCollectionInterface) => { + draft[type][collection.id] = { + permissions: [], read: true, write: false }; - - return result; - }, - {} - ); - } else { - _selectedPermissions[type] = {}; - } - - setSelectedPermissions(_selectedPermissions); - processPermissionsChange(_selectedPermissions); + }); + } else { + draft[type] = {}; + } + }); }; /** @@ -733,21 +878,19 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { - const { id, apiResources } = collection; - const _selectedPermissions: SelectedPermissionsInterface = cloneDeep(selectedPermissions); - - if (e.target.checked) { - _selectedPermissions[type][id] = { - permissions: transformResourceCollectionToPermissions(apiResources.read), - read: true, - write: false - }; - } else { - delete _selectedPermissions[type][id]; - } + const checked: boolean = e.target.checked; - setSelectedPermissions(_selectedPermissions); - processPermissionsChange(_selectedPermissions); + applyLegacySelectionChange((draft: SelectedPermissionsInterface) => { + if (checked) { + draft[type][collection.id] = { + permissions: [], + read: true, + write: false + }; + } else { + delete draft[type][collection.id]; + } + }); }; /** @@ -769,25 +912,13 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { - const { id, apiResources } = collection; - const _selectedPermissions: SelectedPermissionsInterface = cloneDeep(selectedPermissions); - - /** - * In practice `handlePermissionLevelChange` is only called when the - * collection's row checkbox is checked, so `read` is always present. - */ - const legacyCategories: APIResourceCollectionPermissionCategoryInterface[] = - (apiResources[value as keyof typeof apiResources] as - APIResourceCollectionPermissionCategoryInterface[] | undefined) ?? apiResources.read; - - _selectedPermissions[type][id] = { - permissions: transformResourceCollectionToPermissions(legacyCategories), - read: value === "read", - write: value === "write" - }; - - setSelectedPermissions(_selectedPermissions); - processPermissionsChange(_selectedPermissions); + applyLegacySelectionChange((draft: SelectedPermissionsInterface) => { + draft[type][collection.id] = { + permissions: [], + read: value === "read", + write: value === "write" + }; + }); }; /** diff --git a/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts b/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts index aa2e92fa19c..4d00a3fcebc 100644 --- a/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts +++ b/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts @@ -32,8 +32,9 @@ const isPerActionFeatureScope = (scopeName: string): boolean => * when all of these "eligibility" scopes are present, so two levels that resolve to the same * management scopes (differing only by their per-action feature scope) select and clear together. * - * Only meaningful in the granular console-permission model; the legacy read/write path must keep - * evaluating the raw scope names. + * Used in both granular and legacy modes so a role that holds only the underlying management + * (internal_*) scopes lights up the grid even when the cosmetic per-action feature scopes are + * absent from the role's persisted permission list. * * @param scopeNames - The full set of backing scope names for a permission level. * @returns The scope names to use for the granted/eligible check. From ada87daef0944554d0b94ab893e8e3392493732f Mon Sep 17 00:00:00 2001 From: Madhavi Gayathri Date: Mon, 13 Jul 2026 09:43:24 +0530 Subject: [PATCH 2/4] Address PR review comments on console-scopes changes. --- .../console-role-permissions.tsx | 5 +++-- ...eate-console-role-wizard-permissions-form.tsx | 16 +++++++++++----- .../components/user-groups-edit.tsx | 3 ++- .../en-US/portals/console-settings.ts | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx b/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx index f0fcab6c21f..8ce086c43da 100644 --- a/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx +++ b/features/admin.console-settings.v1/components/console-roles/console-roles-edit/console-role-permissions.tsx @@ -110,8 +110,9 @@ const ConsoleRolePermissions: FunctionComponent = ( const { t } = useTranslation(); - const disabledFeatures: string[] = useSelector((state: AppState) => - state?.config?.ui?.features?.consoleSettings?.disabledFeatures); + const disabledFeatures: string[] | undefined = useSelector( + (state: AppState): string[] | undefined => + state?.config?.ui?.features?.consoleSettings?.disabledFeatures); const featureConfig: FeatureConfigInterface = useSelector((state: AppState) => state.config.ui.features); const enabledFeatureOverridesInConsoleRolePermissions: string[] = useSelector( diff --git a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx index 8895140c5cd..3157d6c92f4 100644 --- a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx +++ b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx @@ -218,8 +218,9 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent state.config.ui.enabledFeatureOverridesInConsoleRolePermissions); - const disabledFeatures: string[] = useSelector((state: AppState) => - state?.config?.ui?.features?.consoleSettings?.disabledFeatures); + const disabledFeatures: string[] | undefined = useSelector( + (state: AppState): string[] | undefined => + state?.config?.ui?.features?.consoleSettings?.disabledFeatures); /** * Switches the permissions evaluation between the legacy and the granular mode. @@ -995,11 +996,11 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { const sourceCollections: APIResourceCollectionInterface[] = getSourceCollections(type); + const selectedCount: number = Object.keys(selectedPermissions[type]).length; return { - checked: sourceCollections.length > 0 && - Object.keys(selectedPermissions[type]).length === sourceCollections.length, - indeterminate: false + checked: sourceCollections.length > 0 && selectedCount === sourceCollections.length, + indeterminate: selectedCount > 0 && selectedCount < sourceCollections.length }; }; @@ -1318,6 +1319,11 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent) => { handleSelectAll(e, APIResourceCollectionTypes.TENANT); } } diff --git a/features/admin.users.v1/components/user-groups-edit.tsx b/features/admin.users.v1/components/user-groups-edit.tsx index dd4128489bc..d76138385c0 100644 --- a/features/admin.users.v1/components/user-groups-edit.tsx +++ b/features/admin.users.v1/components/user-groups-edit.tsx @@ -90,7 +90,8 @@ export const UserGroupsList: FunctionComponent = ( const groupsFeatureConfig: FeatureAccessConfigInterface = useSelector( (state: AppState) => state?.config?.ui?.features?.groups); - const hasGroupsUpdatePermission: boolean = useRequiredScopes(groupsFeatureConfig?.scopes?.update); + const groupUpdateScopes: string[] = groupsFeatureConfig?.scopes?.update ?? []; + const hasGroupsUpdatePermission: boolean = useRequiredScopes(groupUpdateScopes); /** * Group memberships are updated through the SCIM2 Groups PATCH API, which requires diff --git a/modules/i18n/src/translations/en-US/portals/console-settings.ts b/modules/i18n/src/translations/en-US/portals/console-settings.ts index 9d8c7841af5..b96c38697df 100644 --- a/modules/i18n/src/translations/en-US/portals/console-settings.ts +++ b/modules/i18n/src/translations/en-US/portals/console-settings.ts @@ -61,7 +61,7 @@ export const consoleSettings: ConsoleSettingsNS = { organizationPermissions: { label: "Organization Permissions" }, - permissionsCount: "{{count}} Permissions", + permissionsCount: "Permissions: {{count}}", tenantPermissions: { label: "Root Organization Permissions" } From 766f0057056a9fe10563e1cc913f02a641fef60d Mon Sep 17 00:00:00 2001 From: Madhavi Gayathri Date: Tue, 14 Jul 2026 08:24:20 +0530 Subject: [PATCH 3/4] Funnel legacy row-level handlers through a shared helper. --- ...e-console-role-wizard-permissions-form.tsx | 72 ++++++++++--------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx index 3157d6c92f4..327263f34c6 100644 --- a/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx +++ b/features/admin.console-settings.v1/components/console-roles/create-console-role-wizard/create-console-role-wizard-permissions-form.tsx @@ -794,6 +794,7 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent, + const setLegacyRowLevel = ( collection: APIResourceCollectionInterface, - type: APIResourceCollectionTypes + type: APIResourceCollectionTypes, + level: { read: boolean; write: boolean } ): void => { - const checked: boolean = e.target.checked; - applyLegacySelectionChange((draft: SelectedPermissionsInterface) => { - if (checked) { - draft[type][collection.id] = { - permissions: [], - read: true, - write: false - }; - } else { + if (!level.read && !level.write) { delete draft[type][collection.id]; + + return; } + + draft[type][collection.id] = { + permissions: [], + read: level.read, + write: level.write + }; }); }; /** - * Handles the permission level toggle change in legacy mode (ToggleButtonGroup). - * - * The `write` level takes precedence: when the user selects "write", only write scopes - * are stored and `read` is set to false. - * - * Not called in granular mode. - * - * @param _ - Mouse event. - * @param collection - Selected API resource collection. - * @param value - Selected permission level. - * @param type - Selected API resource collection type. + * Row-level checkbox change (legacy mode). + */ + const handleSelect = ( + e: ChangeEvent, + collection: APIResourceCollectionInterface, + type: APIResourceCollectionTypes + ): void => { + setLegacyRowLevel(collection, type, { + read: e.target.checked, + write: false + }); + }; + + /** + * Read/write ToggleButtonGroup change (legacy mode). Write takes precedence over read — the two + * flags are mutually exclusive on the persisted entry, mirroring the exclusive toggle in the UI. */ const handlePermissionLevelChange = ( _: MouseEvent, @@ -913,12 +922,9 @@ const CreateConsoleRoleWizardPermissionsForm: FunctionComponent { - applyLegacySelectionChange((draft: SelectedPermissionsInterface) => { - draft[type][collection.id] = { - permissions: [], - read: value === "read", - write: value === "write" - }; + setLegacyRowLevel(collection, type, { + read: value === "read", + write: value === "write" }); }; From 6db1a6f7bf0d380be58ab6f0bef5590b573c8925 Mon Sep 17 00:00:00 2001 From: Madhavi Gayathri Date: Tue, 14 Jul 2026 12:27:52 +0530 Subject: [PATCH 4/4] Strip _view per-action feature scope from eligibility checks. --- .../utils/get-eligibility-scope-names.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts b/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts index 4d00a3fcebc..d6a7bb1af55 100644 --- a/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts +++ b/features/admin.console-settings.v1/utils/get-eligibility-scope-names.ts @@ -17,7 +17,7 @@ */ const FEATURE_SCOPE_PREFIX: string = "console:"; -const PER_ACTION_FEATURE_SCOPE_SUFFIXES: ReadonlyArray = [ "_create", "_update", "_delete", "_edit" ]; +const PER_ACTION_FEATURE_SCOPE_SUFFIXES: ReadonlyArray = [ "_create", "_update", "_delete", "_edit", "_view" ]; /** * Whether a scope is a per-action Console feature scope (see {@link PER_ACTION_FEATURE_SCOPE_SUFFIXES}).