diff --git a/addons/core/addon/components/filter-tags/index.hbs b/addons/core/addon/components/filter-tags/index.hbs index 4e0ffcb336..c341b3fa00 100644 --- a/addons/core/addon/components/filter-tags/index.hbs +++ b/addons/core/addon/components/filter-tags/index.hbs @@ -4,7 +4,7 @@ }} {{#if this.filters}} -
+
{{t 'titles.filters-applied' }} diff --git a/addons/core/addon/styles/addon.scss b/addons/core/addon/styles/addon.scss index a5f0eef3fc..74d3de7664 100644 --- a/addons/core/addon/styles/addon.scss +++ b/addons/core/addon/styles/addon.scss @@ -27,6 +27,7 @@ .search-filtering { .hds-application-state { margin-top: 3rem; + margin-bottom: 3rem; } > div, diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index fd92ad3ef7..7514db174b 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -1243,6 +1243,29 @@ app-token: label: Expires in status: label: Status + scope-options: + label: Scope options + help: Select which scopes the permission is applied to. These will apply to all grant strings. + this: + label: Add this scope + help: Adds the root ({scope}). + children: + label: Add all children + help: Add all of the {scopes} underneath the root. + descendants: + label: Add all descendants + help: Add all of the orgs and the projects in the orgs that are underneath the root. + descendants-selected: + title: Multiple alerts about your scope selection + description: You can't customize scopes if you've selected add all descendants. + custom-scopes: + label: Custom scopes + help: '({itemCount} scopes)' + parent-scope: Parent scope + toggle: '{itemCount, plural, + =0 {Show selected only} + other {Show selected only (#)} + }' status: unknown: Unknown active: Active diff --git a/ui/admin/app/components/scope-options/index.hbs b/ui/admin/app/components/scope-options/index.hbs new file mode 100644 index 0000000000..15367f9644 --- /dev/null +++ b/ui/admin/app/components/scope-options/index.hbs @@ -0,0 +1,234 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{! TODO Consolidate overlapping translations once roles manage scopes gets refactored. }} + +
+ + + {{t 'resources.app-token.form.this.label'}} + {{t + 'resources.app-token.form.this.help' + scope=this.scopeDisplayName + }} + + + + {{t 'resources.app-token.form.children.label'}} + {{t + 'resources.app-token.form.children.help' + scopes=(if @model.scope.isGlobal 'orgs' 'projects') + }} + + + {{#if @model.scope.isGlobal}} + + {{t 'resources.app-token.form.descendants.label'}} + {{t + 'resources.app-token.form.descendants.help' + }} + + {{/if}} + + + {{#if + (and + @model.scope.isGlobal + (includes this.keywords.keyChildren this.grantScopeIds) + ) + }} + + + {{t + 'resources.role.scope.messages.keywords-selected.description' + htmlSafe=true + }} + + {{t 'resources.role.scope.messages.keywords-selected.link'}} + + + + {{/if}} + + {{#if (includes this.keywords.keyDescendants this.grantScopeIds)}} + + {{t + 'resources.app-token.form.descendants-selected.title' + }} + +
    +
  • + {{t + 'resources.role.scope.messages.keywords-selected.description' + htmlSafe=true + }} + {{!TODO: Update with correct doc url for app-tokens.}} + + {{t 'resources.role.scope.messages.keywords-selected.link'}} + +
  • +
  • + {{t + 'resources.app-token.form.descendants-selected.description' + htmlSafe=true + }} +
  • +
+
+
+ {{/if}} + + {{#if this.allowCustomScopesSelection}} + + + <:toggle> + + {{t 'resources.app-token.form.custom-scopes.label'}} + {{#if this.customScopesSelectionTotal}} + {{t + 'resources.app-token.form.custom-scopes.help' + itemCount=this.customScopesSelectionTotal + }} + {{/if}} + + + <:content> + + + + {{#if (has-block 'filter')}} + + {{yield S to='filter'}} + + {{/if}} + + + {{#if this.customScopesSelectionTotal}} + + + {{t + 'resources.app-token.form.custom-scopes.toggle' + itemCount=this.customScopesSelectionTotal + }} + + + {{/if}} + + + + + {{#if @scopes}} + + <:body as |B|> + + + + {{B.data.displayName}} + + + + + + + + {{if + B.data.scope.isGlobal + 'Global' + (or B.data.scope.name B.data.scope.id) + }} + + + + + + + + {{else}} + + + + + {{/if}} + + + + {{/if}} +
\ No newline at end of file diff --git a/ui/admin/app/components/scope-options/index.js b/ui/admin/app/components/scope-options/index.js new file mode 100644 index 0000000000..af4075d88c --- /dev/null +++ b/ui/admin/app/components/scope-options/index.js @@ -0,0 +1,144 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { + GRANT_SCOPE_THIS, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, + GRANT_SCOPE_KEYWORDS, +} from 'api/models/role'; + +export default class ScopeOptionsIndexComponent extends Component { + // =attributes + + keywords = { + keyThis: GRANT_SCOPE_THIS, + keyChildren: GRANT_SCOPE_CHILDREN, + keyDescendants: GRANT_SCOPE_DESCENDANTS, + }; + + /** + * Returns root scope displayName. + * @type {string} + */ + get scopeDisplayName() { + if (this.args.model.scope.isGlobal) { + return 'Global'; + } + return this.args.model.scope.name ?? this.args.model.scope.id; + } + + /** + * Returns selected grant scope ids. + * @type {array} + */ + get grantScopeIds() { + const field = this.args.field || this.args.model; + return field[this.args.name]; + } + + /** + * Returns count of custom scopes selected. + * @type {number} + */ + get customScopesSelectionTotal() { + const customScopes = this.grantScopeIds?.filter( + (scope) => !GRANT_SCOPE_KEYWORDS.includes(scope), + ); + return customScopes?.length; + } + + /** + * Returns true if global role does not have "descendants" toggled on + * or if org role does not have "children" toggled on. + * @type {boolean} + */ + get allowCustomScopesSelection() { + return ( + (this.args.model.scope.isGlobal && + !this.grantScopeIds?.includes(GRANT_SCOPE_DESCENDANTS)) || + (this.args.model.scope.isOrg && + !this.grantScopeIds?.includes(GRANT_SCOPE_CHILDREN)) + ); + } + + // =actions + + /** + * Handles toggle event changes for selected scopes. + * @param {object} event + */ + @action + toggleField(event) { + const { checked, value } = event.target; + const field = this.args.field || this.args.model; + if (!field[this.args.name]) { + field[this.args.name] = []; + } + const removeValues = (values) => { + field[this.args.name] = field[this.args.name].filter( + (item) => !values.some((value) => item.startsWith(value)), + ); + }; + if (checked) { + field[this.args.name] = [...field[this.args.name], value]; + if (value === GRANT_SCOPE_CHILDREN) { + if (this.args.model.scope.isGlobal) { + removeValues([GRANT_SCOPE_DESCENDANTS, 'o_']); + this.args.updateDisplayedScopes(); + } else { + removeValues(['p_']); + } + } + if (value === GRANT_SCOPE_DESCENDANTS) { + removeValues([GRANT_SCOPE_CHILDREN, 'o_', 'p_']); + if (this.args.model.scope.isGlobal) { + this.args.updateDisplayedScopes(); + } + } + } else { + removeValues([value]); + if (this.args.model.scope.isGlobal && value === GRANT_SCOPE_CHILDREN) { + this.args.updateDisplayedScopes(); + } + } + } + + /** + * Handles the selection changes for the paginated table. + * @param {object} selectableRowsStates + */ + @action + selectionChange({ selectableRowsStates }) { + const field = this.args.field || this.args.model; + selectableRowsStates.forEach((row) => { + const { isSelected, selectionKey: key } = row; + const includesId = field[this.args.name].includes(key); + if (isSelected && !includesId) { + field[this.args.name] = [...field[this.args.name], key]; + } else if (!isSelected && includesId) { + field[this.args.name] = field[this.args.name].filter( + (item) => item !== key, + ); + } + }); + } + + /** + * Refreshes table data to only show selected rows. + * @param {object} event + */ + @action + toggleShowSelectedOnly(event) { + const { checked } = event.target; + let showSelectedOnly = false; + if (checked) { + showSelectedOnly = true; + } + this.args.updateDisplayedScopes(showSelectedOnly); + } +} diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index 6a0318038e..9e9bce29e3 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -1067,3 +1067,7 @@ text-decoration-color: currentcolor; } } + +.grant-scope-selection-alert-list { + list-style: disc; +} diff --git a/ui/admin/tests/integration/components/scope-options/index-test.js b/ui/admin/tests/integration/components/scope-options/index-test.js new file mode 100644 index 0000000000..55481ed9ce --- /dev/null +++ b/ui/admin/tests/integration/components/scope-options/index-test.js @@ -0,0 +1,127 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | scope-options/index', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + const THIS_TOGGLE = '[name="this"]'; + const DESCENDANTS_TOGGLE = '[name="descendants"]'; + const CHILDREN_TOGGLE = '[name="children"]'; + const SINGLE_ALERT = '.hds-alert--type-compact'; + const MULTIPLE_ALERTS = '.hds-alert--type-inline'; + + let globalScope, orgScope, projectScope, model; + + hooks.beforeEach(function () { + globalScope = { + isGlobal: true, + id: 'global', + name: 'Global', + displayName: 'Global', + }; + globalScope.scope = globalScope; + orgScope = { + isOrg: true, + id: 'o_123', + name: 'org123', + displayName: 'org123', + scope: globalScope, + }; + projectScope = { + id: 'p_123', + name: 'proj123', + displayName: 'proj123', + scope: orgScope, + }; + model = { + appToken: { id: 'atp_123', scope: globalScope }, + scopes: [globalScope, orgScope, projectScope], + permission: { grant_scope_id: [] }, + totalItems: 2, + page: 1, + pageSize: 10, + }; + const filters = { + allFilters: { parentScopes: [globalScope, orgScope] }, + selectedFilters: { parentScopes: [] }, + }; + this.set('filters', filters); + this.set('handleSearchInput', () => {}); + this.set('updateDisplayedScopes', () => {}); + }); + + test('it renders scope options with descendants toggle when in global scope level', async function (assert) { + this.set('model', model); + + await render( + hbs``, + ); + + assert.dom(THIS_TOGGLE).isVisible(); + assert.dom(DESCENDANTS_TOGGLE).isVisible(); + assert.dom(CHILDREN_TOGGLE).isVisible(); + }); + + test('it renders scope options without descendants toggle when in org scope level', async function (assert) { + model.appToken.scope = orgScope; + this.set('model', model); + + await render( + hbs``, + ); + + assert.dom(THIS_TOGGLE).isVisible(); + assert.dom(CHILDREN_TOGGLE).isVisible(); + assert.dom(DESCENDANTS_TOGGLE).doesNotExist(); + }); + + test('it renders single alert when children is selected in global scope level', async function (assert) { + model.permission.grant_scope_id = ['children']; + this.set('model', model); + + await render( + hbs``, + ); + + assert.dom(CHILDREN_TOGGLE).isChecked(); + assert.dom(DESCENDANTS_TOGGLE).isNotChecked(); + assert.dom(SINGLE_ALERT).isVisible(); + }); + + test('it renders multiple alerts when descendants is selected in global scope level', async function (assert) { + model.permission.grant_scope_id = ['descendants']; + this.set('model', model); + + await render( + hbs``, + ); + + assert.dom(DESCENDANTS_TOGGLE).isChecked(); + assert.dom(CHILDREN_TOGGLE).isNotChecked(); + assert.dom(MULTIPLE_ALERTS).isVisible(); + }); + + test('it renders no alerts when children is selected in org scope level', async function (assert) { + model.appToken.scope = orgScope; + model.permission.grant_scope_id = ['children']; + this.set('model', model); + + await render( + hbs``, + ); + + assert.dom(CHILDREN_TOGGLE).isChecked(); + assert.dom(DESCENDANTS_TOGGLE).doesNotExist(); + assert.dom(MULTIPLE_ALERTS).doesNotExist(); + assert.dom(SINGLE_ALERT).doesNotExist(); + }); +});