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