From eb365f0d3991cd24f8ff51f689b786eac569340c Mon Sep 17 00:00:00 2001
From: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
Date: Fri, 6 Jun 2025 16:26:34 -0700
Subject: [PATCH] Icu 17197 update logic in global roles to only add project
scopes when using children (#2874)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: 💡 hide manage custom scopes button when using keyword
* refactor: 💡 update to only allow projects for global roles
* refactor: 💡 clean-up
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17197
---
addons/core/translations/resources/en-us.yaml | 5 +-
.../form/role/manage-custom-scopes/index.hbs | 205 +++++++++++++-----
.../form/role/manage-custom-scopes/index.js | 26 ++-
.../manage-scopes/manage-custom-scopes.js | 45 +++-
.../manage-scopes/manage-custom-scopes.js | 91 +++++---
ui/admin/app/styles/app.scss | 4 +
.../manage-scopes/manage-custom-scopes.hbs | 14 +-
.../scopes/scope/roles/role/scopes.hbs | 2 +-
.../acceptance/roles/global-scope-test.js | 113 ++++++++++
9 files changed, 405 insertions(+), 100 deletions(-)
diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml
index e524bfdab..db10a8a63 100644
--- a/addons/core/translations/resources/en-us.yaml
+++ b/addons/core/translations/resources/en-us.yaml
@@ -631,8 +631,11 @@ role:
link: You can read more about it here
manage-custom-scopes:
title: Manage Custom Scopes
- description: Select which specific orgs you want to apply to this role's grant sets.
+ description:
+ 0: Select which specific orgs you want to apply to this role's grant sets.
+ 1: Select specific projects from each org to apply to this role's grant sets. You cannot add specific orgs because applying "children" has already added them to this role.
success: Custom scope selection saved
+ tooltip: The org this project is under.
manage-org-projects:
title: Manage Projects in {orgDisplayName}
description: Select which projects in this org you want to apply to this role's grant sets.
diff --git a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs
index a23439d9c..b213f15ad 100644
--- a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs
+++ b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs
@@ -19,68 +19,153 @@
aria-label={{t 'actions.search'}}
{{on 'input' @handleSearchInput}}
/>
+ {{#unless @model.canSelectOrgs}}
+
+
+
+ {{/unless}}
- {{#if @model.orgScopes}}
-
- {{#if @model.role.grantScopeOrgIDs.length}}
-
- {{t
- 'resources.role.scope.titles.selected'
- items=@model.role.grantScopeOrgIDs.length
- total=@model.totalItemsCount
- }}
-
- {{/if}}
-
-
- <:body as |B|>
-
-
-
- {{B.data.displayName}}
-
-
-
-
-
-
- {{#let (get @model.projectTotals B.data.id) as |projectTotals|}}
- {{#if projectTotals}}
-
+
+ {{#if @model.scopes}}
+ {{#if @model.canSelectOrgs}}
+
+ {{#if @model.role.grantScopeOrgIDs.length}}
+
+ {{t
+ 'resources.role.scope.titles.selected'
+ items=@model.role.grantScopeOrgIDs.length
+ total=@model.totalItemsCount
+ }}
+
+ {{/if}}
+
+
+
+ <:body as |B|>
+
+
+
+ {{B.data.displayName}}
+
+
+
+
+
+
+ {{#let (get @model.projectTotals B.data.id) as |projectTotals|}}
+ {{#if projectTotals}}
+
+ {{/if}}
+ {{/let}}
+
+
+
+
+ {{else}}
+
+
+
+ {{#if @model.role.grantScopeProjectIDs.length}}
+
+ {{t
+ 'resources.role.scope.titles.selected'
+ items=@model.role.grantScopeProjectIDs.length
+ total=@model.totalItemsCount
+ }}
+
+ {{/if}}
+
+
+
+ <:body as |B|>
+
+
+
+ {{B.data.displayName}}
+
+
+
+
+
+
+ {{#if (can 'read model' B.data)}}
+
+ {{~or B.data.scope.name B.data.scope.id}}
+
+ {{else}}
+ {{or B.data.scope.name B.data.scope.id}}
{{/if}}
- {{/let}}
-
-
-
-
+
+
+
+
+ {{/if}}
id === this.selectedOrg,
);
return org.displayName;
@@ -27,12 +27,12 @@ export default class FormRoleManageCustomScopesIndexComponent extends Component
// =actions
/**
- * Handles the selection changes for the paginated table.
+ * Handles the org selection changes for the paginated table.
* @param {object} selectableRowsStates
* @param {object} selectionKey
*/
@action
- selectionChange({ selectableRowsStates, selectionKey }) {
+ orgSelectionChange({ selectableRowsStates, selectionKey }) {
const { role, projectTotals } = this.args.model;
const addOrRemoveValues = (add, remove, orgId) => {
@@ -109,4 +109,24 @@ export default class FormRoleManageCustomScopesIndexComponent extends Component
toggleRemoveAllModal() {
this.selectedOrgs = [];
}
+
+ /**
+ * Handles the project selection changes for the paginated table.
+ * @param {object} selectableRowsStates
+ */
+ @action
+ projectSelectionChange({ selectableRowsStates }) {
+ const { role } = this.args.model;
+ selectableRowsStates.forEach((row) => {
+ const { isSelected, selectionKey: key } = row;
+ const includesId = role.grant_scope_ids.includes(key);
+ if (isSelected && !includesId) {
+ role.grant_scope_ids = [...role.grant_scope_ids, key];
+ } else if (!isSelected && includesId) {
+ role.grant_scope_ids = role.grant_scope_ids.filter(
+ (item) => item !== key,
+ );
+ }
+ });
+ }
}
diff --git a/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js b/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js
index 1d8802549..99620c11e 100644
--- a/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js
+++ b/ui/admin/app/controllers/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js
@@ -19,12 +19,43 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesControlle
// =attributes
- queryParams = ['search', 'page', 'pageSize'];
+ queryParams = ['search', { orgs: { type: 'array' } }, 'page', 'pageSize'];
@tracked search;
+ @tracked orgs = [];
@tracked page = 1;
@tracked pageSize = 10;
+ /**
+ * Returns unique org scopes from projects.
+ * @returns {[object]}
+ */
+ get orgScopes() {
+ const uniqueMap = new Map();
+ this.model.allProjects.forEach(({ scope: { id, name } }) => {
+ if (!uniqueMap.has(id)) {
+ const orgName = name || id;
+ uniqueMap.set(id, { id, name: orgName });
+ }
+ });
+ return Array.from(uniqueMap.values());
+ }
+
+ /**
+ * Returns the filters object used for displaying filter tags.
+ * @type {object}
+ */
+ get filters() {
+ return {
+ allFilters: {
+ orgs: this.orgScopes,
+ },
+ selectedFilters: {
+ orgs: this.orgs,
+ },
+ };
+ }
+
// =actions
/**
@@ -38,6 +69,18 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesControlle
this.page = 1;
}
+ /**
+ * Sets a query param to the value of selectedItems
+ * and resets the page to 1.
+ * @param {string} paramKey
+ * @param {[string]} selectedItems
+ */
+ @action
+ applyFilter(paramKey, selectedItems) {
+ this[paramKey] = [...selectedItems];
+ this.page = 1;
+ }
+
/**
* Save grant scope IDs to current role via the API.
* @param {RoleModel} role
diff --git a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js
index 462381b71..479991258 100644
--- a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js
+++ b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js
@@ -9,6 +9,7 @@ import { service } from '@ember/service';
import { TrackedObject } from 'tracked-built-ins';
import { TYPE_SCOPE_PROJECT } from 'api/models/scope';
import { restartableTask, timeout } from 'ember-concurrency';
+import { GRANT_SCOPE_CHILDREN } from 'api/models/role';
export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute extends Route {
// =attributes
@@ -18,6 +19,10 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext
refreshModel: true,
replace: true,
},
+ orgs: {
+ refreshModel: true,
+ replace: true,
+ },
page: {
refreshModel: true,
},
@@ -36,7 +41,7 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext
/**
* Loads sub scopes for the current scope.
- * @returns {Promise<{role: RoleModel, orgScopes: [ScopeModel], projectTotals: object, totalItems: number, totalItemsCount: number}> }
+ * @returns {Promise<{role: RoleModel, orgScopes: [ScopeModel], projectTotals: object, totalItems: number, totalItemsCount: number, canSelectOrgs: boolean}> }
*/
async model(params) {
const useDebounce =
@@ -45,46 +50,75 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext
}
retrieveData = restartableTask(
- async ({ search, page, pageSize, useDebounce }) => {
+ async ({ search, orgs, page, pageSize, useDebounce }) => {
if (useDebounce) {
await timeout(250);
}
const role = this.modelFor('scopes.scope.roles.role');
- const currentScope = this.modelFor('scopes.scope');
- const { id: scope_id } = currentScope;
- const filters = {
- scope_id: [{ equals: scope_id }],
- };
+ const canSelectOrgs =
+ !role.grant_scope_ids.includes(GRANT_SCOPE_CHILDREN);
- const orgScopes = await this.store.query('scope', {
- scope_id,
+ let filters;
+ if (canSelectOrgs) {
+ filters = { scope_id: [{ equals: 'global' }] };
+ } else {
+ filters = { type: [{ equals: TYPE_SCOPE_PROJECT }], scope_id: [] };
+ orgs.forEach((org) => {
+ filters.scope_id.push({ equals: org });
+ });
+ }
+ const scopes = await this.store.query('scope', {
+ scope_id: 'global',
query: { search, filters },
page,
pageSize,
recursive: true,
});
- const totalItems = orgScopes.meta?.totalItems;
+
+ let projectTotals;
+ let allProjects;
+ if (canSelectOrgs) {
+ projectTotals = await this.getProjectTotals(role.grantScopeProjectIDs);
+ } else {
+ allProjects = await this.getAllProjects();
+ }
+
+ const totalItems = scopes.meta?.totalItems;
const totalItemsCount = await this.getTotalItemsCount(
- scope_id,
search,
totalItems,
- );
-
- const projectTotals = await this.getProjectTotals(
- role.grantScopeProjectIDs,
+ canSelectOrgs,
);
return {
role,
- orgScopes,
+ scopes,
projectTotals,
+ allProjects,
totalItems,
totalItemsCount,
+ canSelectOrgs,
};
},
);
+ /**
+ * Retrieves
+ * @returns {Promise<[ScopeModel]>}
+ */
+ async getAllProjects() {
+ const options = { pushToStore: false, peekIndexedDB: true };
+ return this.store.query(
+ 'scope',
+ {
+ scope_id: 'global',
+ query: { filters: { type: [{ equals: TYPE_SCOPE_PROJECT }] } },
+ },
+ options,
+ );
+ }
+
/**
* Creates an object that contains the number of selected projects
* and the total number of projects for each org scope.
@@ -92,15 +126,7 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext
* @returns {object}
*/
async getProjectTotals(projectIDs) {
- const options = { pushToStore: false, peekIndexedDB: true };
- const projects = await this.store.query(
- 'scope',
- {
- scope_id: 'global',
- query: { filters: { type: [{ equals: TYPE_SCOPE_PROJECT }] } },
- },
- options,
- );
+ const projects = await this.getAllProjects();
// We want this object to be tracked so that changes to this object
// cause the "Projects selected" column to re-render with updates.
@@ -121,25 +147,24 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext
/**
* Sets scopesExist to true if there exists any scopes and
* sets totalItemsCount to the number of scopes that exist.
- * @param {string} scope_id
* @param {string} search
* @param {number} totalItems
+ * @param {boolean} canSelectOrgs
* @returns {Promise}
*/
- async getTotalItemsCount(scope_id, search, totalItems) {
+ async getTotalItemsCount(search, totalItems, canSelectOrgs) {
if (!search) {
return totalItems;
}
const options = { pushToStore: false, peekIndexedDB: true };
+ const filters = canSelectOrgs
+ ? { scope_id: [{ equals: 'global' }] }
+ : { type: [{ equals: TYPE_SCOPE_PROJECT }] };
const scopes = await this.store.query(
'scope',
{
- scope_id,
- query: {
- filters: {
- scope_id: [{ equals: scope_id }],
- },
- },
+ scope_id: 'global',
+ query: { filters },
page: 1,
pageSize: 1,
},
diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss
index ef2b365a3..a627600ab 100644
--- a/ui/admin/app/styles/app.scss
+++ b/ui/admin/app/styles/app.scss
@@ -904,6 +904,10 @@
}
.role-manage-custom-scopes-form {
+ .filters-applied {
+ margin-top: 1rem;
+ }
+
.table-selected-items-text {
display: flex;
justify-content: end;
diff --git a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs
index 91880b639..092274500 100644
--- a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs
+++ b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.hbs
@@ -21,7 +21,15 @@
- {{t 'resources.role.scope.messages.manage-custom-scopes.description'}}
+ {{#if @model.canSelectOrgs}}
+ {{t
+ 'resources.role.scope.messages.manage-custom-scopes.description.0'
+ }}
+ {{else}}
+ {{t
+ 'resources.role.scope.messages.manage-custom-scopes.description.1'
+ }}
+ {{/if}}
@@ -30,9 +38,13 @@
diff --git a/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs
index 48a05249c..793b64f81 100644
--- a/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs
+++ b/ui/admin/app/templates/scopes/scope/roles/role/scopes.hbs
@@ -168,7 +168,7 @@
@icon='org'
@iconPosition='leading'
>
- {{~if B.data.scope.name B.data.scope.name B.data.scope.id}}
+ {{~or B.data.scope.name B.data.scope.id}}
{{/if}}
diff --git a/ui/admin/tests/acceptance/roles/global-scope-test.js b/ui/admin/tests/acceptance/roles/global-scope-test.js
index 0eb4b2dfb..9bda46181 100644
--- a/ui/admin/tests/acceptance/roles/global-scope-test.js
+++ b/ui/admin/tests/acceptance/roles/global-scope-test.js
@@ -11,6 +11,7 @@ import {
findAll,
fillIn,
waitUntil,
+ waitFor,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
@@ -714,6 +715,118 @@ module('Acceptance | roles | global-scope', function (hooks) {
.includesText('No results found');
});
+ test('user can select projects when `children` is applied on manage custom scopes page', async function (assert) {
+ instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] });
+ await visit(urls.role);
+
+ await click(commonSelectors.HREF(urls.roleScopes));
+
+ assert.dom(selectors.GRANT_SCOPE_ROW(GRANT_SCOPE_CHILDREN)).isVisible();
+
+ await click(selectors.MANAGE_DROPDOWN_ROLES);
+ await click(selectors.MANAGE_DROPDOWN_ROLES_SCOPES);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+
+ await click(commonSelectors.HREF(urls.manageCustomScopes));
+
+ assert.strictEqual(currentURL(), urls.manageCustomScopes);
+
+ // Click three times to select, unselect, then reselect (for coverage)
+ const projectID = instances.scopes.project.id;
+ await click(selectors.SCOPE_CHECKBOX('project', projectID));
+ await click(selectors.SCOPE_CHECKBOX('project', projectID));
+ await click(selectors.SCOPE_CHECKBOX('project', projectID));
+ await click(commonSelectors.SAVE_BTN);
+
+ assert.strictEqual(currentURL(), urls.manageScopes);
+ assert.dom(selectors.MANAGE_CUSTOM_SCOPES_BUTTON_ICON).isVisible();
+
+ await click(commonSelectors.SAVE_BTN);
+
+ assert.strictEqual(currentURL(), urls.roleScopes);
+ assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 2 });
+ assert.dom(selectors.GRANT_SCOPE_ROW(GRANT_SCOPE_CHILDREN)).isVisible();
+ assert.dom(selectors.GRANT_SCOPE_ROW(projectID)).isVisible();
+ });
+
+ test('user can search for a specific project scope by id on manage custom scopes page', async function (assert) {
+ instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] });
+ const anotherProject = this.server.create('scope', {
+ type: 'project',
+ scope: { id: instances.scopes.org.id, type: 'org' },
+ });
+ await visit(urls.manageScopes);
+
+ await click(commonSelectors.HREF(urls.manageCustomScopes));
+
+ assert
+ .dom(selectors.SCOPE_CHECKBOX('project', instances.scopes.project.id))
+ .isVisible();
+ assert
+ .dom(selectors.SCOPE_CHECKBOX('project', anotherProject.id))
+ .isVisible();
+
+ await fillIn(commonSelectors.SEARCH_INPUT, instances.scopes.project.id);
+ await waitFor(selectors.SCOPE_CHECKBOX('project', anotherProject.id), {
+ count: 0,
+ });
+
+ assert
+ .dom(selectors.SCOPE_CHECKBOX('project', instances.scopes.project.id))
+ .isVisible();
+ assert
+ .dom(selectors.SCOPE_CHECKBOX('project', anotherProject.id))
+ .doesNotExist();
+ });
+
+ test('user can search for a specific project scope by id and get no results on manage custom scopes page', async function (assert) {
+ instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] });
+ await visit(urls.manageScopes);
+
+ await click(commonSelectors.HREF(urls.manageCustomScopes));
+
+ assert
+ .dom(selectors.SCOPE_CHECKBOX('project', instances.scopes.project.id))
+ .isVisible();
+
+ await fillIn(
+ commonSelectors.SEARCH_INPUT,
+ 'fake project scope that does not exist',
+ );
+ await waitFor(selectors.NO_RESULTS_GRANT_SCOPE_MSG, { count: 1 });
+
+ assert
+ .dom(selectors.NO_RESULTS_GRANT_SCOPE_MSG)
+ .includesText('No results found');
+ });
+
+ test('user can filter a project scope by parent scope on manage custom scopes page', async function (assert) {
+ instances.role.update({ grant_scope_ids: [GRANT_SCOPE_CHILDREN] });
+ const anotherOrg = this.server.create('scope', {
+ type: 'org',
+ scope: { id: 'global', type: 'global' },
+ });
+ const anotherProject = this.server.create('scope', {
+ type: 'project',
+ scope: { id: anotherOrg.id, type: 'org' },
+ });
+ await visit(urls.manageScopes);
+
+ await click(commonSelectors.HREF(urls.manageCustomScopes));
+
+ assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 2 });
+
+ await click(commonSelectors.FILTER_DROPDOWN('org'));
+ await click(commonSelectors.FILTER_DROPDOWN_ITEM(anotherOrg.id));
+ await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('org'));
+
+ assert.dom(commonSelectors.TABLE_ROWS).exists({ count: 1 });
+ assert
+ .dom(selectors.SCOPE_CHECKBOX('project', anotherProject.id))
+ .isVisible();
+ });
+
test('user can save custom scopes to add on manage org projects page', async function (assert) {
instances.role.update({ grant_scope_ids: [] });
await visit(urls.role);