diff --git a/addons/api/addon/adapters/role.js b/addons/api/addon/adapters/role.js
new file mode 100644
index 000000000..c9b0c13b0
--- /dev/null
+++ b/addons/api/addon/adapters/role.js
@@ -0,0 +1,14 @@
+import ApplicationAdapter from './application';
+import ScopeAdapterMixin from '../mixins/scope-adapter';
+
+export default class RoleAdapter extends ApplicationAdapter.extend(
+ ScopeAdapterMixin
+) {
+ // =attributes
+
+ /**
+ * Role is scoped to org only, for now.
+ * @type {boolean}
+ */
+ includeProject = false;
+}
diff --git a/addons/api/app/adapters/role.js b/addons/api/app/adapters/role.js
new file mode 100644
index 000000000..f12325923
--- /dev/null
+++ b/addons/api/app/adapters/role.js
@@ -0,0 +1 @@
+export { default } from 'api/adapters/role';
diff --git a/addons/api/tests/unit/adapters/role-test.js b/addons/api/tests/unit/adapters/role-test.js
new file mode 100644
index 000000000..adb7b1abd
--- /dev/null
+++ b/addons/api/tests/unit/adapters/role-test.js
@@ -0,0 +1,16 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+import config from 'ember-get-config';
+
+module('Unit | Adapter | role', function(hooks) {
+ setupTest(hooks);
+
+ test('it generates URL prefixes without project scope', function (assert) {
+ assert.expect(1);
+ const service = this.owner.lookup('service:scope');
+ service.org = { id: 1 };
+ service.project = { id: 2 };
+ const adapter = this.owner.lookup('adapter:role');
+ assert.equal(adapter.urlPrefix(), `${config.api.namespace}/orgs/1`);
+ });
+});
diff --git a/addons/rose/addon/components/rose/layout/global.stories.mdx b/addons/rose/addon/components/rose/layout/global.stories.mdx
index a56e928b9..9b8e34ea4 100644
--- a/addons/rose/addon/components/rose/layout/global.stories.mdx
+++ b/addons/rose/addon/components/rose/layout/global.stories.mdx
@@ -7,12 +7,12 @@ import { text, boolean, select } from '@storybook/addon-knobs';
# Layout|Global
-A top-level layout/positioning component that allows three distinct sections to be configured: header, navigation, and body.
+A top-level layout/positioning component that allows two distinct sections to
+be configured: header and body.
```htmlbars
-
```
@@ -22,7 +22,6 @@ A top-level layout/positioning component that allows three distinct sections to
template: hbs`
Header
- Sidebar
Body
`,
diff --git a/addons/rose/addon/components/rose/layout/sidebar.js b/addons/rose/addon/components/rose/layout/sidebar.js
new file mode 100644
index 000000000..b58f2003a
--- /dev/null
+++ b/addons/rose/addon/components/rose/layout/sidebar.js
@@ -0,0 +1,7 @@
+import Component from '@ember/component';
+import layout from '../../../templates/components/rose/layout/sidebar';
+
+export default Component.extend({
+ layout,
+ tagName: '',
+});
diff --git a/addons/rose/addon/components/rose/layout/sidebar.stories.mdx b/addons/rose/addon/components/rose/layout/sidebar.stories.mdx
new file mode 100644
index 000000000..c24f82584
--- /dev/null
+++ b/addons/rose/addon/components/rose/layout/sidebar.stories.mdx
@@ -0,0 +1,33 @@
+import { Meta, Story, Preview } from '@storybook/addon-docs/blocks';
+import { hbs } from 'ember-cli-htmlbars';
+import { action } from '@storybook/addon-actions';
+import { text, boolean, select } from '@storybook/addon-knobs';
+
+
+
+# Layout|Sidebar
+
+A top-level layout/positioning component that allows two distinct sections to
+be configured: sidebar and body.
+
+```htmlbars
+
+
+ sidebar content
+
+
+ body content
+
+
+```
+
+
+ {{
+ template: hbs`
+
+ Body
+ Sidebar
+
+ `,
+ }}
+
diff --git a/addons/rose/addon/styles/rose/components/layout/_global.scss b/addons/rose/addon/styles/rose/components/layout/_global.scss
index 894f83410..d9b9b771a 100644
--- a/addons/rose/addon/styles/rose/components/layout/_global.scss
+++ b/addons/rose/addon/styles/rose/components/layout/_global.scss
@@ -1,39 +1,10 @@
-@use '../../variables/sizing';
-@use '../../variables/media';
-
.rose-layout-global {
- display: grid;
- grid-template-areas:
- 'header header header header'
- 'navigation body body body';
- // Define 16rem static grid column for navigation
- grid-template-columns: (sizing.rems(m) * 16) auto 1fr;
- grid-template-rows: auto 1fr;
- height: 100vh;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
.rose-layout-global-body {
- grid-area: body;
- padding: sizing.rems(l);
- }
-
- .rose-layout-global-header {
- grid-area: header;
- }
-
- .rose-layout-global-sidebar {
- grid-area: navigation;
- border-right: sizing.rems(xxxxs) solid var(--ui-border-subtler-1);
- }
-}
-
-@media (max-width: media.width(small)) {
- .rose-layout-global {
- grid-template-columns: 1fr;
- grid-template-rows: 0.1fr auto 1fr;
- grid-template-areas:
- 'header'
- 'navigation'
- 'body';
- padding-bottom: 0;
+ flex: 1;
+ display: flex;
}
}
diff --git a/addons/rose/addon/styles/rose/components/layout/_index.scss b/addons/rose/addon/styles/rose/components/layout/_index.scss
index 1ed6d8338..5afb56808 100644
--- a/addons/rose/addon/styles/rose/components/layout/_index.scss
+++ b/addons/rose/addon/styles/rose/components/layout/_index.scss
@@ -1,3 +1,13 @@
+@use '../../variables/sizing';
+
@import 'global';
+@import 'sidebar';
@import 'page';
@import 'centered';
+
+// TODO: this is prescriptive and can be removed if it becomes a problem,
+// but used here reflects the fact that the "main" element, of which there
+// can only be one per page, generally has padding in Structure layouts.
+main {
+ padding: sizing.rems(l);
+}
diff --git a/addons/rose/addon/styles/rose/components/layout/_sidebar.scss b/addons/rose/addon/styles/rose/components/layout/_sidebar.scss
new file mode 100644
index 000000000..e5cc54668
--- /dev/null
+++ b/addons/rose/addon/styles/rose/components/layout/_sidebar.scss
@@ -0,0 +1,27 @@
+@use '../../variables/sizing';
+@use '../../variables/media';
+
+.rose-layout-sidebar {
+ --sidebar-width: #{sizing.rems(m) * 16};
+ display: flex;
+ flex-grow: 1;
+
+ .rose-layout-sidebar-body {
+ flex: 1;
+ }
+
+ .rose-layout-sidebar-sidebar {
+ width: var(--sidebar-width);
+ border-right: sizing.rems(xxxxs) solid var(--ui-border-subtler-1);
+ }
+}
+
+@media (max-width: media.width(small)) {
+ .rose-layout-sidebar {
+ flex-direction: column;
+
+ .rose-layout-sidebar-sidebar {
+ width: auto;
+ }
+ }
+}
diff --git a/addons/rose/addon/templates/components/rose/layout/global.hbs b/addons/rose/addon/templates/components/rose/layout/global.hbs
index 5f516ec80..bfa2c8e75 100644
--- a/addons/rose/addon/templates/components/rose/layout/global.hbs
+++ b/addons/rose/addon/templates/components/rose/layout/global.hbs
@@ -1,7 +1,6 @@
-
+
{{yield (hash
header=(component "rose/anonymous" tagName="header" class="rose-layout-global-header")
- sidebar=(component "rose/anonymous" tagName="aside" class="rose-layout-global-sidebar")
- body=(component "rose/anonymous" tagName="main" class="rose-layout-global-body")
+ body=(component "rose/anonymous" class="rose-layout-global-body")
)}}
-
+
diff --git a/addons/rose/addon/templates/components/rose/layout/sidebar.hbs b/addons/rose/addon/templates/components/rose/layout/sidebar.hbs
new file mode 100644
index 000000000..6bfb342e4
--- /dev/null
+++ b/addons/rose/addon/templates/components/rose/layout/sidebar.hbs
@@ -0,0 +1,6 @@
+
diff --git a/addons/rose/app/components/rose/layout/sidebar.js b/addons/rose/app/components/rose/layout/sidebar.js
new file mode 100644
index 000000000..f078ea1c6
--- /dev/null
+++ b/addons/rose/app/components/rose/layout/sidebar.js
@@ -0,0 +1 @@
+export { default } from 'rose/components/rose/layout/sidebar';
\ No newline at end of file
diff --git a/addons/rose/tests/dummy/app/templates/playground.hbs b/addons/rose/tests/dummy/app/templates/playground.hbs
index 8f7d026b4..cf74a1e48 100644
--- a/addons/rose/tests/dummy/app/templates/playground.hbs
+++ b/addons/rose/tests/dummy/app/templates/playground.hbs
@@ -30,274 +30,24 @@
-
-
- Item Name
- Item Name
- Item Name
- Item Name
-
-
-
-
- Breadcrumbs
- Header
- Navigation
- Actions
- Body
-
- Dummy app index template
+
+
+
+
+ Item Name
+ Item Name
+ Item Name
+ Item Name
+
+
-
- Menu item
- Menu item
- Button menu item
-
+
+ Body content
+
-
- An error occurred.
-
+
- Icon
-
-
-
-
- Button
- Primary
-
- Primary
-
-
- Secondary
-
- Secondary
-
-
- Form
-
-
- Inputs
-
-
-
-
-
- Textareas
-
-
-
-
-
- Selects
-
-
- Choose an option
- Value 1
- Value 2
- Value 3
-
-
-
-
- Choose an option
-
-
-
-
- Radio Group
-
-
-
-
-
-
- Checkboxes
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Address
- Host Set
- Name
- Description
-
-
-
-
- 1.1.1.1
- host-set-1, host-set-2
- optional name
- description
-
-
-
-
- Footer
-
-
-
-
-
- Level 1
- Level 2
- Level 3
- Level 4
- Level 5
-
-
-
-
- You must be granted permissions to view this data.
- Ask your administrator if you think you should have access.
-
-
-
- Go back
-
- Need help?
-
-
-
-
- Sorry, we couldn't find the page you were looking for. You can go back or to one of the links below.
-
-
-
- Go back
-
- Need help?
-
-
-
-
- We ran into a problem and could not continue. You can ask your administrator or try again later.
-
-
-
- Try Again
-
- Need help?
-
-
-
-
- Your logs will appear here when we start receiving them. If your server is currently outputting logs, check your configuration.
-
-
-
-
-
- Namespaces allow you to create distinct areas with their own separate policies, mounts, tokens, and identity entities.
-
- Add a namespace
-
-
-
-
-
- Namespaces allow you to create distinct areas with their own separate policies, mounts, tokens, and identity entities.
-
- Add a namespace
-
-
diff --git a/addons/rose/tests/integration/components/rose/layout/global-test.js b/addons/rose/tests/integration/components/rose/layout/global-test.js
index 0b262991e..2e6b91039 100644
--- a/addons/rose/tests/integration/components/rose/layout/global-test.js
+++ b/addons/rose/tests/integration/components/rose/layout/global-test.js
@@ -8,7 +8,7 @@ module('Integration | Component | rose/layout/global', function (hooks) {
test('it renders', async function (assert) {
await render(hbs``);
- assert.ok(find('div'));
+ assert.ok(find('section'));
assert.ok(find('.rose-layout-global'));
});
@@ -17,13 +17,6 @@ module('Integration | Component | rose/layout/global', function (hooks) {
assert.ok(find('#global'));
});
- test('it renders with content', async function (assert) {
- await render(hbs`
-
- `);
- assert.ok(find('#content'));
- });
-
test('it renders with header', async function (assert) {
await render(hbs`
@@ -31,17 +24,10 @@ module('Integration | Component | rose/layout/global', function (hooks) {
assert.ok(find('.rose-layout-global-header'));
});
- test('it renders with main', async function (assert) {
+ test('it renders with body', async function (assert) {
await render(hbs`
`);
assert.ok(find('.rose-layout-global-body'));
});
-
- test('it renders with sidebar', async function (assert) {
- await render(hbs`
-
- `);
- assert.ok(find('.rose-layout-global-sidebar'));
- });
});
diff --git a/addons/rose/tests/integration/components/rose/layout/sidebar-test.js b/addons/rose/tests/integration/components/rose/layout/sidebar-test.js
new file mode 100644
index 000000000..727710ca1
--- /dev/null
+++ b/addons/rose/tests/integration/components/rose/layout/sidebar-test.js
@@ -0,0 +1,21 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, find } from '@ember/test-helpers';
+import hbs from 'htmlbars-inline-precompile';
+
+module('Integration | Component | rose/layout/sidebar', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders', async function (assert) {
+ assert.expect(3);
+ await render(hbs`
+
+
+
+
+ `);
+ assert.ok(find('.rose-layout-sidebar'));
+ assert.ok(find('.rose-layout-sidebar-body'));
+ assert.ok(find('aside.rose-layout-sidebar-sidebar'));
+ });
+});
diff --git a/ui/core/app/components/form/role/index.hbs b/ui/core/app/components/form/role/index.hbs
new file mode 100644
index 000000000..9db695baa
--- /dev/null
+++ b/ui/core/app/components/form/role/index.hbs
@@ -0,0 +1,49 @@
+
+
+
+ {{#if @model.errors.name}}
+
+ {{#each @model.errors.name as |error|}}
+ {{error.message}}
+ {{/each}}
+
+ {{/if}}
+
+
+
+ {{#if @model.errors.description}}
+
+ {{#each @model.errors.description as |error|}}
+ {{error.message}}
+ {{/each}}
+
+ {{/if}}
+
+
+
+
+
diff --git a/ui/core/app/controllers/orgs/org/roles.js b/ui/core/app/controllers/orgs/org/roles.js
new file mode 100644
index 000000000..7f62b0e1a
--- /dev/null
+++ b/ui/core/app/controllers/orgs/org/roles.js
@@ -0,0 +1,18 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+
+export default class OrgsOrgRolesController extends Controller {
+ // =services
+
+ @service intl;
+
+ // =attributes
+
+ /**
+ * @type {string}
+ */
+ get breadCrumb() {
+ return this.intl.t('resources.roles');
+ }
+
+}
diff --git a/ui/core/app/controllers/orgs/org/roles/new.js b/ui/core/app/controllers/orgs/org/roles/new.js
new file mode 100644
index 000000000..38a098eaa
--- /dev/null
+++ b/ui/core/app/controllers/orgs/org/roles/new.js
@@ -0,0 +1,18 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+
+export default class OrgsOrgRolesNewController extends Controller {
+ // =services
+
+ @service intl;
+
+ // =attributes
+
+ /**
+ * @type {string}
+ */
+ get breadCrumb() {
+ return this.intl.t('titles.new');
+ }
+
+}
diff --git a/ui/core/app/controllers/orgs/org/roles/role.js b/ui/core/app/controllers/orgs/org/roles/role.js
new file mode 100644
index 000000000..85022f933
--- /dev/null
+++ b/ui/core/app/controllers/orgs/org/roles/role.js
@@ -0,0 +1,12 @@
+import Controller from '@ember/controller';
+import { alias } from '@ember/object/computed';
+
+export default class OrgsOrgRolesRoleController extends Controller {
+ // =attributes
+
+ /**
+ * @type {string}
+ */
+ @alias('model.name') breadCrumb;
+
+}
diff --git a/ui/core/app/router.js b/ui/core/app/router.js
index 58b16952b..6be92febe 100644
--- a/ui/core/app/router.js
+++ b/ui/core/app/router.js
@@ -23,6 +23,11 @@ Router.map(function () {
this.route('user', { path: ':user_id' }, function() {});
this.route('new');
});
+
+ this.route('roles', function() {
+ this.route('role', { path: ':role_id' }, function() {});
+ this.route('new');
+ });
});
});
});
diff --git a/ui/core/app/routes/orgs/org/projects/project.js b/ui/core/app/routes/orgs/org/projects/project.js
index 5c0b8b632..8f06533bc 100644
--- a/ui/core/app/routes/orgs/org/projects/project.js
+++ b/ui/core/app/routes/orgs/org/projects/project.js
@@ -36,7 +36,7 @@ export default class OrgsOrgProjectsProjectRoute extends Route {
*/
renderTemplate(controller, model) {
this.render('orgs/org/projects/project/_sidebar', {
- into: 'application',
+ into: 'orgs/org',
outlet: 'sidebar',
model: model
});
diff --git a/ui/core/app/routes/orgs/org/roles.js b/ui/core/app/routes/orgs/org/roles.js
new file mode 100644
index 000000000..807c2aeee
--- /dev/null
+++ b/ui/core/app/routes/orgs/org/roles.js
@@ -0,0 +1,14 @@
+import Route from '@ember/routing/route';
+
+export default class OrgsOrgRolesRoute extends Route {
+ // =methods
+
+ /**
+ * Returns all roles from store.
+ * @return {Promise[RoleModel]}
+ */
+ model() {
+ return this.store.findAll('role');
+ }
+
+}
diff --git a/ui/core/app/routes/orgs/org/roles/index.js b/ui/core/app/routes/orgs/org/roles/index.js
new file mode 100644
index 000000000..89b868927
--- /dev/null
+++ b/ui/core/app/routes/orgs/org/roles/index.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class OrgsOrgRolesIndexRoute extends Route { }
diff --git a/ui/core/app/routes/orgs/org/roles/new.js b/ui/core/app/routes/orgs/org/roles/new.js
new file mode 100644
index 000000000..cad253804
--- /dev/null
+++ b/ui/core/app/routes/orgs/org/roles/new.js
@@ -0,0 +1,50 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class OrgsOrgRolesNewRoute extends Route {
+ // =services
+
+ @service intl;
+ @service notify;
+
+ // =methods
+
+ /**
+ * Returns a new unsaved role.
+ * @return {Promise{RoleModel}}
+ */
+ model() {
+ return this.store.createRecord('role');
+ }
+
+ // =actions
+
+ /**
+ * Rollback changes on role by destroying unsaved instance
+ * and redirect to role index.
+ * @param {RoleModel} role
+ */
+ @action
+ cancel(role) {
+ role.rollbackAttributes();
+ this.transitionTo('orgs.org.roles');
+ }
+
+ /**
+ * Handle saving a role.
+ * @param {RoleModel} role
+ * @param {Event} e
+ */
+ @action
+ async save(role) {
+ try {
+ await role.save();
+ this.transitionTo('orgs.org.roles.role', role);
+ this.notify.success(this.intl.t('notify.role.create-success'));
+ } catch (error) {
+ // TODO: replace with translated strings
+ this.notify.error(error.message, { closeAfter: null });
+ }
+ }
+}
diff --git a/ui/core/app/routes/orgs/org/roles/role.js b/ui/core/app/routes/orgs/org/roles/role.js
new file mode 100644
index 000000000..1820f2ba1
--- /dev/null
+++ b/ui/core/app/routes/orgs/org/roles/role.js
@@ -0,0 +1,64 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class OrgsOrgRolesRoleRoute extends Route {
+ // =services
+
+ @service intl;
+ @service notify;
+
+ // =methods
+
+ /**
+ * Return a role with id
+ * @param {object} params
+ * @return {Promise{RoleModel}}
+ */
+ model({ role_id: id }) {
+ return this.store.findRecord('role', id);
+ }
+
+ // =actions
+
+ /**
+ * Rollback changes on role.
+ * @param {RoleModel} role
+ */
+ @action
+ cancel(role) {
+ role.rollbackAttributes();
+ }
+
+ /**
+ * Handle saving a role.
+ * @param {RoleModel} role
+ * @param {Event} e
+ */
+ @action
+ async save(role) {
+ try {
+ await role.save();
+ this.notify.success(this.intl.t('notify.save-success'));
+ } catch (error) {
+ // TODO: replace with translated strings
+ this.notify.error(error.message, { closeAfter: null });
+ }
+ }
+
+ /**
+ * Delete role and redirect to roles index.
+ * @param {RoleModel} role
+ */
+ @action
+ async delete(role) {
+ try {
+ await role.destroyRecord();
+ this.transitionTo('orgs.org.roles');
+ this.notify.success(this.intl.t('notify.role.delete-success'));
+ } catch (error) {
+ // TODO: replace with translated strings
+ this.notify.error(error.message, { closeAfter: null });
+ }
+ }
+}
diff --git a/ui/core/app/styles/app.scss b/ui/core/app/styles/app.scss
index 56d7f26df..0aaf60c5b 100644
--- a/ui/core/app/styles/app.scss
+++ b/ui/core/app/styles/app.scss
@@ -5,6 +5,11 @@
.rose-nav-sidebar {
margin-top: sizing.rems(xxl);
+ margin-bottom: sizing.rems(l);
+
+ & + .rose-nav-sidebar {
+ margin-top: 0;
+ }
}
.rose-form {
diff --git a/ui/core/app/templates/application.hbs b/ui/core/app/templates/application.hbs
index 7181b7f54..576e7875a 100644
--- a/ui/core/app/templates/application.hbs
+++ b/ui/core/app/templates/application.hbs
@@ -25,9 +25,9 @@
- {{outlet "sidebar"}}
-
- {{outlet}}
+
+ {{outlet}}
+
diff --git a/ui/core/app/templates/orgs/org.hbs b/ui/core/app/templates/orgs/org.hbs
index c24cd6895..17926a1aa 100644
--- a/ui/core/app/templates/orgs/org.hbs
+++ b/ui/core/app/templates/orgs/org.hbs
@@ -1 +1,27 @@
-{{outlet}}
+
+
+
+
+
+
+ {{t "resources.projects"}}
+
+
+
+ {{outlet "sidebar"}}
+
+
+
+ {{t "resources.roles"}}
+
+
+
+
+
+
+
+ {{outlet}}
+
+
+
+
diff --git a/ui/core/app/templates/orgs/org/roles.hbs b/ui/core/app/templates/orgs/org/roles.hbs
new file mode 100644
index 000000000..6d511b7f7
--- /dev/null
+++ b/ui/core/app/templates/orgs/org/roles.hbs
@@ -0,0 +1,3 @@
+{{title (t "resources.roles")}}
+
+{{outlet}}
diff --git a/ui/core/app/templates/orgs/org/roles/index.hbs b/ui/core/app/templates/orgs/org/roles/index.hbs
new file mode 100644
index 000000000..498aecac5
--- /dev/null
+++ b/ui/core/app/templates/orgs/org/roles/index.hbs
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+ {{t "resources.roles"}}
+
+
+
+ {{#if @model}}
+ {{t 'titles.new'}}
+ {{/if}}
+
+
+
+
+ {{#if @model}}
+
+
+
+
+ {{t 'form.label.name'}}
+ {{t 'form.label.description'}}
+
+
+
+ {{#each @model as |role|}}
+
+
+
+ {{role.name}}
+
+
+ {{role.description}}
+
+ {{/each}}
+
+
+
+ {{else}}
+
+
+
+
+ {{t 'descriptions.roles-welcome'}}
+
+
+
+ {{t 'titles.new'}}
+
+
+
+
+ {{/if}}
+
+
+
+
diff --git a/ui/core/app/templates/orgs/org/roles/new.hbs b/ui/core/app/templates/orgs/org/roles/new.hbs
new file mode 100644
index 000000000..911310f26
--- /dev/null
+++ b/ui/core/app/templates/orgs/org/roles/new.hbs
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ {{t "titles.new"}}
+
+
+
+
+
+
diff --git a/ui/core/app/templates/orgs/org/roles/role.hbs b/ui/core/app/templates/orgs/org/roles/role.hbs
new file mode 100644
index 000000000..2b05f838d
--- /dev/null
+++ b/ui/core/app/templates/orgs/org/roles/role.hbs
@@ -0,0 +1,3 @@
+{{title @model.name}}
+
+{{outlet}}
diff --git a/ui/core/app/templates/orgs/org/roles/role/index.hbs b/ui/core/app/templates/orgs/org/roles/role/index.hbs
new file mode 100644
index 000000000..1b35c1929
--- /dev/null
+++ b/ui/core/app/templates/orgs/org/roles/role/index.hbs
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+ {{t "resources.role"}}
+
+
+
+
+
+
+ {{t 'actions.delete'}}
+
+
+
+
diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js
index f6dd9bf6d..ca2462ef9 100644
--- a/ui/core/mirage/config.js
+++ b/ui/core/mirage/config.js
@@ -41,12 +41,18 @@ export default function() {
this.del('/orgs/:org_id/auth-methods/:id');
// IAM : Users
-
this.get('/orgs/:org_id/users');
this.post('/orgs/:org_id/users');
this.get('/orgs/:org_id/users/:id');
this.patch('/orgs/:org_id/users/:id');
this.del('/orgs/:org_id/users/:id');
+
+ // IAM: Roles
+ this.get('/orgs/:org_id/roles');
+ this.post('/orgs/:org_id/roles');
+ this.get('/orgs/:org_id/roles/:id');
+ this.patch('/orgs/:org_id/roles/:id');
+ this.del('/orgs/:org_id/roles/:id');
// Other resources
// host-catalog
diff --git a/ui/core/mirage/factories/role.js b/ui/core/mirage/factories/role.js
new file mode 100644
index 000000000..abcb8de67
--- /dev/null
+++ b/ui/core/mirage/factories/role.js
@@ -0,0 +1,3 @@
+import factory from '../generated/factories/role';
+
+export default factory.extend();
diff --git a/ui/core/mirage/generated/factories/role.js b/ui/core/mirage/generated/factories/role.js
new file mode 100644
index 000000000..6265c7116
--- /dev/null
+++ b/ui/core/mirage/generated/factories/role.js
@@ -0,0 +1,16 @@
+import { Factory } from 'ember-cli-mirage';
+import { random, date } from 'faker';
+
+/**
+ * GeneratedRoleModelFactory
+ * Role contains all field related to a Role resource
+ */
+export default Factory.extend({
+
+ name: () => random.words(),
+ description: () => random.words(),
+ created_time: () => date.recent(),
+ updated_time: () => date.recent(),
+ disabled: () => random.boolean(),
+
+});
diff --git a/ui/core/mirage/scenarios/default.js b/ui/core/mirage/scenarios/default.js
index ad7dfe2f0..0f542378f 100644
--- a/ui/core/mirage/scenarios/default.js
+++ b/ui/core/mirage/scenarios/default.js
@@ -15,6 +15,9 @@ export default function(server) {
// User
server.createList('user', 5);
+ // Role
+ server.createList('role', 5);
+
// Other resources
server.createList('host-catalog', 6);
diff --git a/ui/core/mirage/serializers/role.js b/ui/core/mirage/serializers/role.js
new file mode 100644
index 000000000..c0db8a130
--- /dev/null
+++ b/ui/core/mirage/serializers/role.js
@@ -0,0 +1,5 @@
+import ApplicationSerializer from './application';
+
+export default ApplicationSerializer.extend({
+ modelName: 'role'
+});
diff --git a/ui/core/tests/acceptance/roles-test.js b/ui/core/tests/acceptance/roles-test.js
new file mode 100644
index 000000000..976ba47b7
--- /dev/null
+++ b/ui/core/tests/acceptance/roles-test.js
@@ -0,0 +1,191 @@
+import { module, test } from 'qunit';
+import { visit, currentURL, click, fillIn, find } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
+import a11yAudit from 'ember-a11y-testing/test-support/audit';
+import { Response } from 'miragejs';
+
+module('Acceptance | roles', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.server.create('org');
+ });
+
+ test('visiting roles', async function (assert) {
+ assert.expect(1);
+ await visit('/orgs/1/roles');
+ await a11yAudit();
+ assert.equal(currentURL(), '/orgs/1/roles');
+ });
+
+ test('can create new role', async function (assert) {
+ assert.expect(4);
+ assert.equal(this.server.db.roles.length, 0);
+ await visit('/orgs/1/roles/new');
+ await fillIn('[name="name"]', 'role name');
+ await click('[type="submit"]');
+ assert.equal(currentURL(), '/orgs/1/roles/1');
+ assert.equal(this.server.db.roles.length, 1);
+ assert.equal(this.server.db.roles[0].name, 'role name');
+ });
+
+ test('can cancel new role creation', async function (assert) {
+ assert.expect(3);
+ assert.equal(this.server.db.roles.length, 0);
+ await visit('/orgs/1/roles/new');
+ await fillIn('[name="name"]', 'role name');
+ await click('.rose-form-actions [type="button"]');
+ assert.equal(currentURL(), '/orgs/1/roles');
+ assert.equal(this.server.db.roles.length, 0);
+ });
+
+ test('saving a new role with invalid fields displays error messages', async function (assert) {
+ assert.expect(2);
+ this.server.post('/orgs/:org_id/roles', () => {
+ return new Response(
+ 400,
+ {},
+ {
+ status: 400,
+ code: 'invalid_argument',
+ message: 'The request was invalid.',
+ details: {
+ fields: [
+ {
+ name: 'name',
+ message: 'Name is required.',
+ },
+ ],
+ },
+ }
+ );
+ });
+ await visit('/orgs/1/roles/new');
+ await fillIn('[name="name"]', 'role name');
+ await click('[type="submit"]');
+ assert.ok(
+ find('[role="alert"]').textContent.trim(),
+ 'The request was invalid.',
+ 'Displays primary error message.'
+ );
+ assert.ok(
+ find('.rose-form-error-message').textContent.trim(),
+ 'Name is required.',
+ 'Displays field-level errors.'
+ );
+ });
+
+ test('can save changes to an existing role', async function (assert) {
+ assert.expect(2);
+ this.server.createList('role', 1, {name: 'Admin role'});
+ await visit('/orgs/1/roles/1');
+ await fillIn('[name="name"]', 'Updated admin role');
+ await click('.rose-form-actions [type="submit"]');
+ assert.equal(currentURL(), '/orgs/1/roles/1');
+ assert.equal(this.server.db.roles[0].name, 'Updated admin role');
+ });
+
+ test('can cancel changes to an existing role', async function (assert) {
+ assert.expect(1);
+ this.server.createList('role', 1, {name: 'Admin role'});
+ await visit('/orgs/1/roles/1');
+ await fillIn('[name="name"]', 'Updated admin role');
+ await click('.rose-form-actions [type="button"]');
+ assert.equal(find('[name="name"]').value, 'Admin role');
+ });
+
+ test('can delete a role', async function(assert) {
+ assert.expect(2);
+ this.server.createList('role', 1);
+ assert.equal(this.server.db.roles.length, 1);
+ await visit('/orgs/1/roles/1');
+ await click('.rose-button-warning');
+ assert.equal(this.server.db.roles.length, 0);
+ });
+
+ test('saving an existing role with invalid fields displays error messages', async function (assert) {
+ assert.expect(2);
+ this.server.createList('role', 1);
+ this.server.patch('/orgs/:org_id/roles/:id', () => {
+ return new Response(
+ 400,
+ {},
+ {
+ status: 400,
+ code: 'invalid_argument',
+ message: 'The request was invalid.',
+ details: {
+ fields: [
+ {
+ name: 'name',
+ message: 'Name is required.',
+ },
+ ],
+ },
+ }
+ );
+ });
+ await visit('/orgs/1/roles/1');
+ await fillIn('[name="name"]', 'random string');
+ await click('[type="submit"]');
+ assert.ok(
+ find('[role="alert"]').textContent.trim(),
+ 'The request was invalid.',
+ 'Displays primary error message.'
+ );
+ assert.ok(
+ find('.rose-form-error-message').textContent.trim(),
+ 'Name is required.',
+ 'Displays field-level errors.'
+ );
+ });
+
+ test('errors are displayed when save project fails', async function (assert) {
+ assert.expect(1);
+ this.server.createList('role', 1);
+ this.server.patch('/orgs/:org_id/roles/:id', () => {
+ return new Response(
+ 490,
+ {},
+ {
+ status: 490,
+ code: 'error',
+ message: 'Oops.',
+ }
+ );
+ });
+ await visit('/orgs/1/roles/1');
+ await fillIn('[name="name"]', 'Role name');
+ await click('[type="submit"]');
+ assert.ok(
+ find('[role="alert"]').textContent.trim(),
+ 'Oops.',
+ 'Displays primary error message.'
+ );
+ });
+
+ test('errors are displayed when delete project fails', async function (assert) {
+ assert.expect(1);
+ this.server.createList('role', 1);
+ this.server.del('/orgs/:org_id/roles/:id', () => {
+ return new Response(
+ 490,
+ {},
+ {
+ status: 490,
+ code: 'error',
+ message: 'Oops.',
+ }
+ );
+ });
+ await visit('/orgs/1/roles/1');
+ await click('.rose-button-warning');
+ assert.ok(
+ find('[role="alert"]').textContent.trim(),
+ 'Oops.',
+ 'Displays primary error message.'
+ );
+ });
+});
diff --git a/ui/core/tests/unit/controllers/orgs/org/roles-test.js b/ui/core/tests/unit/controllers/orgs/org/roles-test.js
new file mode 100644
index 000000000..816459cd3
--- /dev/null
+++ b/ui/core/tests/unit/controllers/orgs/org/roles-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Controller | orgs/org/roles', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let controller = this.owner.lookup('controller:orgs/org/roles');
+ assert.ok(controller);
+ });
+});
diff --git a/ui/core/tests/unit/controllers/orgs/org/roles/new-test.js b/ui/core/tests/unit/controllers/orgs/org/roles/new-test.js
new file mode 100644
index 000000000..8d948ae06
--- /dev/null
+++ b/ui/core/tests/unit/controllers/orgs/org/roles/new-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Controller | orgs/org/roles/new', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let controller = this.owner.lookup('controller:orgs/org/roles/new');
+ assert.ok(controller);
+ });
+});
diff --git a/ui/core/tests/unit/controllers/orgs/org/roles/role-test.js b/ui/core/tests/unit/controllers/orgs/org/roles/role-test.js
new file mode 100644
index 000000000..cd56c941f
--- /dev/null
+++ b/ui/core/tests/unit/controllers/orgs/org/roles/role-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Controller | orgs/org/roles/role', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let controller = this.owner.lookup('controller:orgs/org/roles/role');
+ assert.ok(controller);
+ });
+});
diff --git a/ui/core/tests/unit/routes/orgs/org/roles-test.js b/ui/core/tests/unit/routes/orgs/org/roles-test.js
new file mode 100644
index 000000000..38bc3acb2
--- /dev/null
+++ b/ui/core/tests/unit/routes/orgs/org/roles-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | orgs/org/roles', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:orgs/org/roles');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/tests/unit/routes/orgs/org/roles/index-test.js b/ui/core/tests/unit/routes/orgs/org/roles/index-test.js
new file mode 100644
index 000000000..12f247adb
--- /dev/null
+++ b/ui/core/tests/unit/routes/orgs/org/roles/index-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | orgs/org/roles/index', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:orgs/org/roles/index');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/tests/unit/routes/orgs/org/roles/new-test.js b/ui/core/tests/unit/routes/orgs/org/roles/new-test.js
new file mode 100644
index 000000000..d613eb34a
--- /dev/null
+++ b/ui/core/tests/unit/routes/orgs/org/roles/new-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | orgs/org/roles/new', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:orgs/org/roles/new');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/tests/unit/routes/orgs/org/roles/role-test.js b/ui/core/tests/unit/routes/orgs/org/roles/role-test.js
new file mode 100644
index 000000000..149fd657e
--- /dev/null
+++ b/ui/core/tests/unit/routes/orgs/org/roles/role-test.js
@@ -0,0 +1,11 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'ember-qunit';
+
+module('Unit | Route | orgs/org/roles/role', function(hooks) {
+ setupTest(hooks);
+
+ test('it exists', function(assert) {
+ let route = this.owner.lookup('route:orgs/org/roles/role');
+ assert.ok(route);
+ });
+});
diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml
index e2b3c8f9b..636a127fb 100644
--- a/ui/core/translations/en-us.yaml
+++ b/ui/core/translations/en-us.yaml
@@ -6,25 +6,31 @@ actions:
help: Need help?
resources:
user: User
- users: Users
+ users: Users
+ role: Role
+ roles: Roles
projects: Projects
project: Project
host-catalog: Host Catalog
host-catalogs: Host Catalogs
titles:
+ iam: IAM
new: New
+ general: General
overview: Overview
settings: Settings
empty-set: No Items Yet
projects-welcome: Welcome to Projects
host-catalogs-welcome: Welcome to Host Catalogs
users-welcome: Welcome to Users
+ roles-welcome: Welcome to Roles
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.
projects-welcome: Projects allow you to create and manage host catalogs. To start, create a project.
host-catalogs-welcome: Host catalogs allow you to configure to collect and create hosts and host sets. To start, create a host catalog.
users-welcome: Users are authorized to access Watchtower.
+ roles-welcome: Roles are collections of capabilities that can be granted to user and/or groups.
states:
info: Info
success: Success
@@ -53,6 +59,9 @@ notify:
user:
create-success: User created succesfully.
delete-success: Deleted user successfully.
+ role:
+ create-success: Role created succesfully.
+ delete-success: Deleted role successfully.
dev:
default-org: Default Org
menu-item: Menu item