Icu 17197 update logic in global roles to only add project scopes when using children (#2874)

* 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
This commit is contained in:
Lisbet Alvarez authored and GitHub committed 2025-06-06 16:26:34 -07:00
1 parent 57e614e8db
commit eb365f0d39
9 files changed
+405 -100

No files matched your search

@@ -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.
@@ -19,68 +19,153 @@
aria-label={{t 'actions.search'}}
{{on 'input' @handleSearchInput}}
/>
{{#unless @model.canSelectOrgs}}
<S.Generic>
<Dropdown
name='org'
@toggleText={{t 'resources.org.title'}}
@itemOptions={{@orgScopes}}
@checkedItems={{@orgs}}
@applyFilter={{fn @applyFilter 'orgs'}}
@isSearchable={{true}}
/>
</S.Generic>
{{/unless}}
</Hds::SegmentedGroup>
{{#if @model.orgScopes}}
<div class='table-selected-items-text'>
{{#if @model.role.grantScopeOrgIDs.length}}
<Hds::Text::Body @tag='p' @color='faint'>
{{t
'resources.role.scope.titles.selected'
items=@model.role.grantScopeOrgIDs.length
total=@model.totalItemsCount
}}
</Hds::Text::Body>
{{/if}}
</div>
<Hds::Table
@columns={{array
(hash label=(t 'resources.role.scope.title'))
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.role.scope.titles.projects_selected'))
}}
@model={{@model.orgScopes}}
@isSelectable={{true}}
@onSelectionChange={{this.selectionChange}}
@valign='middle'
>
<:body as |B|>
<B.Tr
@selectionKey={{B.data.id}}
@isSelected={{includes B.data.id @model.role.grantScopeOrgIDs}}
@selectionAriaLabelSuffix='row {{B.data.id}}'
data-test-org-scopes-table-row={{B.data.id}}
>
<B.Td>
<Hds::Text::Body @tag='p'>
{{B.data.displayName}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Copy::Snippet
@textToCopy={{B.data.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
{{#let (get @model.projectTotals B.data.id) as |projectTotals|}}
{{#if projectTotals}}
<Hds::Link::Standalone
@icon='arrow-right'
@iconPosition='trailing'
@text={{t
'resources.role.scope.actions.view-projects'
selected=projectTotals.selected.length
total=projectTotals.total
}}
@route='scopes.scope.roles.role.manage-scopes.manage-org-projects'
@model={{B.data.id}}
/>
{{#if @model.scopes}}
{{#if @model.canSelectOrgs}}
<div class='table-selected-items-text'>
{{#if @model.role.grantScopeOrgIDs.length}}
<Hds::Text::Body @tag='p' @color='faint'>
{{t
'resources.role.scope.titles.selected'
items=@model.role.grantScopeOrgIDs.length
total=@model.totalItemsCount
}}
</Hds::Text::Body>
{{/if}}
</div>
<Hds::Table
@columns={{array
(hash label=(t 'resources.role.scope.title'))
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.role.scope.titles.projects_selected'))
}}
@model={{@model.scopes}}
@isSelectable={{true}}
@onSelectionChange={{this.orgSelectionChange}}
@valign='middle'
>
<:body as |B|>
<B.Tr
@selectionKey={{B.data.id}}
@isSelected={{includes B.data.id @model.role.grantScopeOrgIDs}}
@selectionAriaLabelSuffix='row {{B.data.id}}'
data-test-org-scopes-table-row={{B.data.id}}
>
<B.Td>
<Hds::Text::Body @tag='p'>
{{B.data.displayName}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Copy::Snippet
@textToCopy={{B.data.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
{{#let (get @model.projectTotals B.data.id) as |projectTotals|}}
{{#if projectTotals}}
<Hds::Link::Standalone
@icon='arrow-right'
@iconPosition='trailing'
@text={{t
'resources.role.scope.actions.view-projects'
selected=projectTotals.selected.length
total=projectTotals.total
}}
@route='scopes.scope.roles.role.manage-scopes.manage-org-projects'
@model={{B.data.id}}
/>
{{/if}}
{{/let}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{else}}
<FilterTags @filters={{@filters}} />
<div class='table-selected-items-text'>
{{#if @model.role.grantScopeProjectIDs.length}}
<Hds::Text::Body @tag='p' @color='faint'>
{{t
'resources.role.scope.titles.selected'
items=@model.role.grantScopeProjectIDs.length
total=@model.totalItemsCount
}}
</Hds::Text::Body>
{{/if}}
</div>
<Hds::Table
@columns={{array
(hash label=(t 'resources.project.title'))
(hash label=(t 'form.id.label'))
(hash
label=(t 'resources.org.title')
tooltip=(t
'resources.role.scope.messages.manage-custom-scopes.tooltip'
)
)
}}
@model={{@model.scopes}}
@isSelectable={{true}}
@onSelectionChange={{this.projectSelectionChange}}
@valign='middle'
>
<:body as |B|>
<B.Tr
@selectionKey={{B.data.id}}
@isSelected={{includes
B.data.id
@model.role.grantScopeProjectIDs
}}
@selectionAriaLabelSuffix='row {{B.data.id}}'
data-test-project-scopes-table-row={{B.data.id}}
>
<B.Td>
<Hds::Text::Body @tag='p'>
{{B.data.displayName}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Copy::Snippet
@textToCopy={{B.data.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
{{#if (can 'read model' B.data)}}
<Hds::Link::Inline
@route='scopes.scope'
@model={{B.data.scope.id}}
@icon='org'
@iconPosition='leading'
>
{{~or B.data.scope.name B.data.scope.id}}
</Hds::Link::Inline>
{{else}}
{{or B.data.scope.name B.data.scope.id}}
{{/if}}
{{/let}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{/if}}
<Rose::Pagination
@model={{@model.role.id}}
@totalItems={{@model.totalItems}}
@@ -18,7 +18,7 @@ export default class FormRoleManageCustomScopesIndexComponent extends Component
* @type {string}
*/
get orgDisplayName() {
const org = this.args.model.orgScopes.find(
const org = this.args.model.scopes.find(
({ id }) => 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,
);
}
});
}
}
@@ -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
@@ -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<number>}
*/
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,
},
+4
View File
@@ -904,6 +904,10 @@
}
.role-manage-custom-scopes-form {
.filters-applied {
margin-top: 1rem;
}
.table-selected-items-text {
display: flex;
justify-content: end;
@@ -21,7 +21,15 @@
<DocLink @doc='role.add-grant-scopes' />
</PH.Title>
<PH.Description>
{{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}}
</PH.Description>
</Hds::PageHeader>
</page.header>
@@ -30,9 +38,13 @@
<Form::Role::ManageCustomScopes
@model={{@model}}
@search={{this.search}}
@filters={{this.filters}}
@orgs={{this.orgs}}
@orgScopes={{this.orgScopes}}
@page={{this.page}}
@pageSize={{this.pageSize}}
@handleSearchInput={{this.handleSearchInput}}
@applyFilter={{this.applyFilter}}
@submit={{fn this.setGrantScopes @model.role}}
@cancel={{fn this.cancel @model.role}}
/>
@@ -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}}
</Hds::Link::Inline>
{{/if}}
</B.Td>
@@ -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);