diff --git a/addons/api/addon/adapters/application.js b/addons/api/addon/adapters/application.js
index 09b772cb7..187c94ad1 100644
--- a/addons/api/addon/adapters/application.js
+++ b/addons/api/addon/adapters/application.js
@@ -1,4 +1,5 @@
import RESTAdapter from '@ember-data/adapter/rest';
+import { serializeIntoHash } from '@ember-data/adapter/-private';
import AdapterBuildURLMixin from '../mixins/adapter-build-url';
import config from 'ember-get-config';
import { get, getWithDefault } from '@ember/object';
@@ -143,6 +144,26 @@ export default class ApplicationAdapter extends RESTAdapter.extend(
return super.query(...arguments).then(prenormalizeArrayResponse);
}
+ /**
+ * Our API expects update calls with a custom method suffix
+ * (e.g. `:set-grants`) to be made with POST instead of PUT/PATCH.
+ * If this method is called with a custom method via `adapterOptions.method`,
+ * the API request will be made with POST. All other update requests are
+ * made as normal.
+ * @override
+ * @param {Store} store
+ * @param {Model} type
+ * @param {Snapshot} snapshot
+ * @return {Promise} promise
+ */
+ updateRecord(store, type, snapshot) {
+ const method = get(snapshot, 'adapterOptions.method');
+ const data = serializeIntoHash(store, type, snapshot, {});
+ const id = snapshot.id;
+ const url = this.buildURL(type.modelName, id, snapshot, 'updateRecord');
+ return this.ajax(url, method ? 'POST' : 'PUT', { data });
+ }
+
/**
* Overrides default ajax method by rewritting PUT requests as PATCH.
* PATCH is the request method used by our API for updates.
diff --git a/addons/api/addon/generated/models/role.js b/addons/api/addon/generated/models/role.js
index 254005ed5..0177972a0 100644
--- a/addons/api/addon/generated/models/role.js
+++ b/addons/api/addon/generated/models/role.js
@@ -33,4 +33,10 @@ export default class GeneratedRoleModel extends BaseModel {
description: 'Whether the resource is disabled',
})
disabled;
+
+ @attr('number', {
+ description: 'Current version number of this resource.',
+ })
+ version;
+
}
diff --git a/addons/api/addon/models/fragment-string.js b/addons/api/addon/models/fragment-string.js
new file mode 100644
index 000000000..78ddb80b5
--- /dev/null
+++ b/addons/api/addon/models/fragment-string.js
@@ -0,0 +1,16 @@
+import Fragment from 'ember-data-model-fragments/fragment';
+import { attr } from '@ember-data/model';
+
+/**
+ * Represents an item in an array of strings.
+ */
+export default class FragmentStringModel extends Fragment {
+
+ // =attributes
+
+ @attr('string', {
+ description: 'The value of the string.'
+ })
+ value;
+
+}
diff --git a/addons/api/addon/models/role.js b/addons/api/addon/models/role.js
index dbb69dce8..ccd4d1048 100644
--- a/addons/api/addon/models/role.js
+++ b/addons/api/addon/models/role.js
@@ -1,3 +1,30 @@
import GeneratedRoleModel from '../generated/models/role';
+import { fragmentArray } from 'ember-data-model-fragments/attributes';
-export default class RoleModel extends GeneratedRoleModel {}
+export default class RoleModel extends GeneratedRoleModel {
+
+ // =attributes
+
+ /**
+ * Grants is read-only _most_ under normal circumstances. But grants can
+ * be persisted via a dedicated call to `saveGrants()`.
+ */
+ @fragmentArray('fragment-string', {readOnly: true}) grants;
+
+ // =methods
+
+ /**
+ * Saves the `grants` array on the role via the `set-grants` method.
+ * See serializer and adapter for more information.
+ * @return {Promise}
+ */
+ saveGrants() {
+ return this.save({
+ adapterOptions: {
+ method: 'set-grants',
+ serializeGrants: true
+ }
+ });
+ }
+
+}
diff --git a/addons/api/addon/serializers/role.js b/addons/api/addon/serializers/role.js
new file mode 100644
index 000000000..9cf12da58
--- /dev/null
+++ b/addons/api/addon/serializers/role.js
@@ -0,0 +1,52 @@
+import ApplicationSerializer from './application';
+import { get } from '@ember/object';
+
+export default class RoleSerializer extends ApplicationSerializer {
+
+ // =methods
+
+ /**
+ * If `adapterOptions.serializeGrants` is true, the serialization should
+ * include **only grants** and the version. Normally, grants are not
+ * serialized.
+ * @override
+ * @param {Snapshot} snapshot
+ * @return {object}
+ */
+ serialize(snapshot) {
+ const serializeGrants = get(snapshot, 'adapterOptions.serializeGrants');
+ let serialized = super.serialize(...arguments);
+ if (serializeGrants) serialized = this.serializeWithGrants(snapshot);
+ return serialized;
+ }
+
+ /**
+ * Returns a payload containing only the grants array.
+ * @param {Snapshot} snapshot
+ * @return {object}
+ */
+ serializeWithGrants(snapshot) {
+ return {
+ version: snapshot.attr('version'),
+ grant_strings: snapshot.attr('grants').map(grant => grant.attr('value'))
+ };
+ }
+
+ /**
+ * Converts string arrays to object arrays for use with `fragment-string`.
+ * E.g. `"foo"` -> `{value: "foo"}`
+ * @override
+ * @param {Model} typeClass
+ * @param {Object} hash
+ * @return {Object}
+ */
+ normalize(modelClass, resourceHash) {
+ // TODO: can fragment string normalization be handled generically?
+ if (resourceHash.grant_strings) {
+ resourceHash.grants =
+ resourceHash.grant_strings.map(value => ({ value }));
+ }
+ return super.normalize(modelClass, resourceHash);
+ }
+
+}
diff --git a/addons/api/app/models/fragment-string.js b/addons/api/app/models/fragment-string.js
new file mode 100644
index 000000000..90c1321af
--- /dev/null
+++ b/addons/api/app/models/fragment-string.js
@@ -0,0 +1 @@
+export { default } from 'api/models/fragment-string';
diff --git a/addons/api/app/serializers/role.js b/addons/api/app/serializers/role.js
new file mode 100644
index 000000000..58c592caf
--- /dev/null
+++ b/addons/api/app/serializers/role.js
@@ -0,0 +1 @@
+export { default } from 'api/serializers/role';
diff --git a/addons/api/tests/unit/adapters/application-test.js b/addons/api/tests/unit/adapters/application-test.js
index c3d72d469..dc984bba2 100644
--- a/addons/api/tests/unit/adapters/application-test.js
+++ b/addons/api/tests/unit/adapters/application-test.js
@@ -112,6 +112,23 @@ module('Unit | Adapter | application', function (hooks) {
adapter.ajax('/', 'PUT');
});
+ test('it rewrites PUT/PATCH to POST when `adapterOptions.method` is passed to `updateRecord`', function (assert) {
+ assert.expect(1);
+ const adapter = this.owner.lookup('adapter:application');
+ const store = this.owner.lookup('service:store');
+ const snapshot = store.createRecord('role', {})._createSnapshot();
+ snapshot.adapterOptions = {
+ method: 'custom-method'
+ }
+ // TODO this is icky, should be changed to a spy or stub
+ const originalAjax = RESTAdapter.prototype.ajax;
+ RESTAdapter.prototype.ajax = (url, type) => {
+ assert.equal(type, 'POST');
+ RESTAdapter.prototype.ajax = originalAjax;
+ };
+ adapter.updateRecord(store, {modelName: 'user'}, snapshot);
+ });
+
test('it prenormalizes "empty" responses into a form that the fetch-manager will not reject', async function (assert) {
assert.expect(1);
const adapter = this.owner.lookup('adapter:application');
diff --git a/addons/api/tests/unit/models/fragment-string-test.js b/addons/api/tests/unit/models/fragment-string-test.js
new file mode 100644
index 000000000..8bfd2c018
--- /dev/null
+++ b/addons/api/tests/unit/models/fragment-string-test.js
@@ -0,0 +1,13 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Model | fragment string', function(hooks) {
+ setupTest(hooks);
+
+ // Replace this with your real tests.
+ test('it exists', function(assert) {
+ let store = this.owner.lookup('service:store');
+ let model = store.createRecord('fragment-string', {});
+ assert.ok(model);
+ });
+});
diff --git a/addons/api/tests/unit/models/role-test.js b/addons/api/tests/unit/models/role-test.js
index b47f31343..fe1b8224b 100644
--- a/addons/api/tests/unit/models/role-test.js
+++ b/addons/api/tests/unit/models/role-test.js
@@ -1,12 +1,44 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
+import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
module('Unit | Model | role', function (hooks) {
setupTest(hooks);
+ setupMirage(hooks);
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('role', {});
assert.ok(model);
});
+
+ test('it has a `saveGrants` method that targets a specific POST API endpoint and serialization', async function (assert) {
+ assert.expect(1);
+ this.server.post('/v1/scopes/o_1/roles/123abc:set-grants', (schema, request) => {
+ const body = JSON.parse(request.requestBody);
+ assert.deepEqual(body, {
+ grant_strings: ['grant1', 'grant2'],
+ version: 1
+ });
+ });
+ const store = this.owner.lookup('service:store');
+ store.push({
+ data: {
+ id: '123abc',
+ type: 'role',
+ attributes: {
+ name: 'Role',
+ description: 'Description',
+ grants: [{value: 'grant1'}, {value: 'grant2'}],
+ version: 1,
+ scope: {
+ scope_id: 'o_1',
+ type: 'scope'
+ }
+ }
+ }
+ });
+ const model = store.peekRecord('role', '123abc');
+ await model.saveGrants();
+ });
});
diff --git a/addons/api/tests/unit/serializers/role-test.js b/addons/api/tests/unit/serializers/role-test.js
new file mode 100644
index 000000000..1293d28b4
--- /dev/null
+++ b/addons/api/tests/unit/serializers/role-test.js
@@ -0,0 +1,77 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Serializer | role', function(hooks) {
+ setupTest(hooks);
+
+ test('it serializes roles normally, without grants', function (assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ const serializer = store.serializerFor('role');
+ const record = store.createRecord('role', {
+ name: 'User',
+ description: 'Description',
+ grants: [{value: 'foo'}, {value: 'bar'}],
+ version: 1
+ });
+ const snapshot = record._createSnapshot();
+ snapshot.adapterOptions = {};
+ const serializedRecord = serializer.serialize(snapshot);
+ assert.deepEqual(serializedRecord, {
+ name: 'User',
+ description: 'Description',
+ version: 1
+ });
+ });
+
+ test('it serializes only grants when `adapterOptions.serializeGrants` is true', function (assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ const serializer = store.serializerFor('role');
+ const record = store.createRecord('role', {
+ name: 'User',
+ description: 'Description',
+ grants: [{value: 'foo'}, {value: 'bar'}],
+ version: 1
+ });
+ const snapshot = record._createSnapshot();
+ snapshot.adapterOptions = {
+ serializeGrants: true
+ };
+ const serializedRecord = serializer.serialize(snapshot);
+ assert.deepEqual(serializedRecord, {
+ grant_strings: ['foo', 'bar'],
+ version: 1
+ });
+ });
+
+ test('it normalizes records with array fields', function (assert) {
+ assert.expect(1);
+ const store = this.owner.lookup('service:store');
+ const serializer = store.serializerFor('role');
+ const roleModelClass = store.createRecord('role').constructor;
+ const payload = {
+ id: '1',
+ name: 'Role 1',
+ grant_strings: ['*', '*']
+ };
+ const normalized = serializer.normalizeSingleResponse(
+ store,
+ roleModelClass,
+ payload
+ );
+ assert.deepEqual(normalized, {
+ included: [],
+ data: {
+ id: '1',
+ type: 'role',
+ attributes: {
+ name: 'Role 1',
+ grants: [{value: '*'}, {value: '*'}],
+ },
+ relationships: {},
+ },
+ });
+ });
+
+});
diff --git a/addons/auth/addon/authenticators/password.js b/addons/auth/addon/authenticators/password.js
index 7c6d79a4e..67c8ce422 100644
--- a/addons/auth/addon/authenticators/password.js
+++ b/addons/auth/addon/authenticators/password.js
@@ -49,19 +49,19 @@ export default class PasswordAuthenticator extends BaseAuthenticator {
*
* @override
* @param {object} creds
- * @param {string} creds.identification is sent to server as `name`
+ * @param {string} creds.identification is sent to server as `login_name`
* @param {string} creds.password
* @param {boolean} requestCookies request cookie tokens (default `true`)
* @return {Promise}
*/
async authenticate(
- { identification: name, password },
+ { identification: login_name, password },
requestCookies = true,
options
) {
const body = JSON.stringify({
token_type: requestCookies ? 'cookie' : null,
- credentials: { name, password },
+ credentials: { login_name, password },
});
const authEndpointURL = this.buildAuthEndpointURL(options);
const response = await fetch(authEndpointURL, { method: 'post', body });
diff --git a/addons/auth/tests/unit/authenticators/password-test.js b/addons/auth/tests/unit/authenticators/password-test.js
index aebdfee56..372d57a0c 100644
--- a/addons/auth/tests/unit/authenticators/password-test.js
+++ b/addons/auth/tests/unit/authenticators/password-test.js
@@ -41,7 +41,7 @@ module('Unit | Authenticator | password', function (hooks) {
assert.deepEqual(json, {
token_type: 'cookie',
credentials: {
- name: 'foo',
+ login_name: 'foo',
password: 'bar',
},
});
diff --git a/addons/rose/addon/styles/rose/components/list/_key-value.scss b/addons/rose/addon/styles/rose/components/list/_key-value.scss
index a6b54eb24..fcce12059 100644
--- a/addons/rose/addon/styles/rose/components/list/_key-value.scss
+++ b/addons/rose/addon/styles/rose/components/list/_key-value.scss
@@ -2,6 +2,10 @@
@use '../../variables/typography';
@use '../../utilities/type';
+.rose-list-key-value {
+ margin-bottom: sizing.rems(m);
+}
+
.rose-list-key-value-item {
display: flex;
border-bottom: 1px solid var(--ui-gray-subtler-3);
diff --git a/addons/rose/addon/templates/components/rose/form.hbs b/addons/rose/addon/templates/components/rose/form.hbs
index 6c74ccae4..f37f36d09 100644
--- a/addons/rose/addon/templates/components/rose/form.hbs
+++ b/addons/rose/addon/templates/components/rose/form.hbs
@@ -3,8 +3,9 @@
novalidate
method="post"
class="rose-form"
+ autocomplete="off"
{{on "submit" this.handleSubmit}}
->
+>
{{yield (hash
radioGroup=(component 'rose/form/radio/group' disabled=@disabled)
checkbox=(component 'rose/form/checkbox' disabled=@disabled)
diff --git a/ui/core/app/components/form/role/grants/index.hbs b/ui/core/app/components/form/role/grants/index.hbs
new file mode 100644
index 000000000..098621b45
--- /dev/null
+++ b/ui/core/app/components/form/role/grants/index.hbs
@@ -0,0 +1,91 @@
+
+
+ {{!-- Add grant field (only one per form) --}}
+
+
+
+ {{t "titles.new-grant"}}
+
+
+
+
+
+
+ {{t "actions.add"}}
+
+
+
+
+
+
+
+
+
+ {{!--
+ {{#if @model.errors.grants}}
+
+ {{#each @model.errors.grants as |error|}}
+ {{error.message}}
+ {{/each}}
+
+ {{/if}}
+ --}}
+
+
+ {{#each @model.grants as |grant|}}
+
+
+
+ {{t "form.label.grant"}}
+
+
+
+
+
+
+
+ {{t "actions.remove"}}
+
+
+
+ {{/each}}
+
+
+
+
+
diff --git a/ui/core/app/components/form/role/grants/index.js b/ui/core/app/components/form/role/grants/index.js
new file mode 100644
index 000000000..509d4356f
--- /dev/null
+++ b/ui/core/app/components/form/role/grants/index.js
@@ -0,0 +1,37 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { computed, action } from '@ember/object';
+
+export default class FormRoleGrantsComponent extends Component {
+
+ // =attributes
+
+ /**
+ * @type {string}
+ */
+ @tracked newGrantString = '';
+
+ /**
+ * True if the grant string field is empty, false otherwise. This is used
+ * to disable the submit button.
+ * @return {boolean}
+ */
+ @computed('newGrantString')
+ get cannotSave() {
+ return !this.newGrantString;
+ }
+
+ // =actions
+
+ /**
+ * Calls the passed function with the grant string as an argument and then
+ * clears the value of the grant string field.
+ * `@addGrant` should be passed by the context calling this component.
+ * @param {Function} addGrantFn
+ */
+ @action
+ createGrant(addGrantFn) {
+ addGrantFn(this.newGrantString);
+ this.newGrantString = '';
+ }
+}
diff --git a/ui/core/app/router.js b/ui/core/app/router.js
index a4f121c19..bba93d1ad 100644
--- a/ui/core/app/router.js
+++ b/ui/core/app/router.js
@@ -21,7 +21,9 @@ Router.map(function () {
this.route('new');
});
this.route('roles', function () {
- this.route('role', { path: ':role_id' }, function () {});
+ this.route('role', { path: ':role_id' }, function () {
+ this.route('grants');
+ });
this.route('new');
});
this.route('projects', function() {
diff --git a/ui/core/app/routes/scopes/scope/roles/role/grants.js b/ui/core/app/routes/scopes/scope/roles/role/grants.js
new file mode 100644
index 000000000..a46277957
--- /dev/null
+++ b/ui/core/app/routes/scopes/scope/roles/role/grants.js
@@ -0,0 +1,54 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class ScopesScopeRolesRoleGrantsRoute extends Route {
+
+ // =services
+
+ @service intl;
+ @service notify;
+
+ // =methods
+
+ /**
+ * Adds a new grant to the role at the beginning of the grants list.
+ * Grant creation is not immediately permanent; users may rollback the change
+ * via "cancel" or commit it via "save".
+ * @param {RoleModel} role
+ * @param {string} grantString
+ */
+ @action
+ addGrant(role, grantString) {
+ role.grants.unshiftObject({ value: grantString });
+ }
+
+ /**
+ * Removes a grant from the role. Grant removal is not immediately permanent;
+ * users may rollback the change via "cancel" or commit it via "save".
+ * @param {RoleModel} role
+ * @param {FragmentString} grant
+ */
+ @action
+ removeGrant(role, grant) {
+ role.grants.removeFragment(grant);
+ }
+
+ /**
+ * Save an role in current scope.
+ * @param {RoleModel} role
+ */
+ @action
+ async save(role) {
+ try {
+ await role.saveGrants();
+ this.notify.success(
+ this.intl.t('notify.save-success')
+ );
+ } catch (error) {
+ // TODO: replace with translated strings
+ this.notify.error(error.message, { closeAfter: null });
+ }
+ }
+
+}
diff --git a/ui/core/app/templates/scopes/scope/roles/role.hbs b/ui/core/app/templates/scopes/scope/roles/role.hbs
index d66e83390..552717330 100644
--- a/ui/core/app/templates/scopes/scope/roles/role.hbs
+++ b/ui/core/app/templates/scopes/scope/roles/role.hbs
@@ -1,3 +1,28 @@
{{title @model.displayName}}
-{{outlet}}
+
+
+
+
+
+
+
+ {{t "resources.role"}}
+
+
+
+
+
+ {{t 'titles.settings'}}
+
+
+ {{t 'titles.grants'}}
+
+
+
+
+
+ {{outlet}}
+
+
+
diff --git a/ui/core/app/templates/scopes/scope/roles/role/grants.hbs b/ui/core/app/templates/scopes/scope/roles/role/grants.hbs
new file mode 100644
index 000000000..edb0faf13
--- /dev/null
+++ b/ui/core/app/templates/scopes/scope/roles/role/grants.hbs
@@ -0,0 +1,9 @@
+{{title (t 'titles.grants')}}
+
+
diff --git a/ui/core/app/templates/scopes/scope/roles/role/index.hbs b/ui/core/app/templates/scopes/scope/roles/role/index.hbs
index c3d9734e0..e72610ec6 100644
--- a/ui/core/app/templates/scopes/scope/roles/role/index.hbs
+++ b/ui/core/app/templates/scopes/scope/roles/role/index.hbs
@@ -1,31 +1,6 @@
-
+
-
-
-
-
-
- {{t "resources.role"}}
-
-
-
-
-
- {{t 'actions.delete-role'}}
-
-
-
-
-
-
-
-
-
diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js
index 5d54a9320..73342c6b0 100644
--- a/ui/core/mirage/config.js
+++ b/ui/core/mirage/config.js
@@ -64,6 +64,13 @@ export default function() {
this.get('/scopes/:scope_id/roles/:id');
this.patch('/scopes/:scope_id/roles/:id');
this.del('/scopes/:scope_id/roles/:id');
+ this.post('/scopes/:scope_id/roles/:id', function ({ roles }, request) {
+ const attrs = this.normalizedRequestAttrs();
+ const id = request.params.id.split(':')[0];
+ const role = roles.find(id);
+ attrs.id = id;
+ return role.update(attrs);
+ });
// IAM: Groups
this.get('/scopes/:scope_id/groups');
diff --git a/ui/core/mirage/factories/role.js b/ui/core/mirage/factories/role.js
index abcb8de67..739b273e6 100644
--- a/ui/core/mirage/factories/role.js
+++ b/ui/core/mirage/factories/role.js
@@ -1,3 +1,11 @@
import factory from '../generated/factories/role';
-export default factory.extend();
+export default factory.extend({
+
+ // eslint-disable-next-line ember/avoid-leaking-state-in-ember-objects
+ grant_strings: [
+ 'id=*;action=*',
+ 'id=*;type=host-catalog;actions=create,read'
+ ],
+
+});
diff --git a/ui/core/mirage/route-handlers/auth.js b/ui/core/mirage/route-handlers/auth.js
index 596500f46..ed2942c2f 100644
--- a/ui/core/mirage/route-handlers/auth.js
+++ b/ui/core/mirage/route-handlers/auth.js
@@ -12,7 +12,7 @@ import { Response } from 'miragejs';
// */
// export default function authenticateHandler(schema, request) {
// const payload = JSON.parse(request.requestBody);
-// if (payload.credentials.name === 'error') {
+// if (payload.credentials.login_name === 'error') {
// return new Response(400);
// } else {
// const cookieName = config.auth.passwordCookieName;
@@ -25,7 +25,7 @@ import { Response } from 'miragejs';
export function authHandler({ scopes }, request) {
const payload = JSON.parse(request.requestBody);
- if (payload.credentials.name === 'error') {
+ if (payload.credentials.login_name === 'error') {
return new Response(400);
} else {
const scopeAttrs =
diff --git a/ui/core/tests/acceptance/roles-test.js b/ui/core/tests/acceptance/roles-test.js
index dd8483574..7db363fbe 100644
--- a/ui/core/tests/acceptance/roles-test.js
+++ b/ui/core/tests/acceptance/roles-test.js
@@ -217,4 +217,16 @@ module('Acceptance | roles', function (hooks) {
'Displays primary error message.'
);
});
+
+ test('grants can be updated and saved or canceled', async function (assert) {
+ assert.expect(0);
+ });
+
+ test('grants can be removed and saved or canceled', async function (assert) {
+ assert.expect(0);
+ });
+
+ test('new grants can be added and saved or canceled', async function (assert) {
+ assert.expect(0);
+ });
});
diff --git a/ui/core/tests/unit/routes/scopes/scope/roles/role/grants-test.js b/ui/core/tests/unit/routes/scopes/scope/roles/role/grants-test.js
new file mode 100644
index 000000000..87b0c03c7
--- /dev/null
+++ b/ui/core/tests/unit/routes/scopes/scope/roles/role/grants-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | scopes/scope/roles/role/grants', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:scopes/scope/roles/role/grants');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml
index 181bcbf12..7cc53edc9 100644
--- a/ui/core/translations/en-us.yaml
+++ b/ui/core/translations/en-us.yaml
@@ -1,4 +1,5 @@
actions:
+ add: Add
cancel: Cancel
delete: Delete
dismiss: Dismiss
@@ -47,6 +48,8 @@ titles:
users-welcome: Welcome to Users
roles-welcome: Welcome to Roles
groups-welcome: Welcome to Groups
+ grants: Permissions
+ new-grant: New Grant
descriptions:
host-catalogs: Overview of collections of hosts and host sets.
empty-set: There are no items to display yet. You may be able to add items or try back later.
@@ -69,6 +72,7 @@ form:
identification: Username
password: Password
manage: Manage
+ grant: Grant
errors:
titles:
unauthenticated-error: You are not signed in