diff --git a/addons/auth/addon/.gitkeep b/addons/auth/addon/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/addons/auth/addon/authenticators/password.js b/addons/auth/addon/authenticators/password.js new file mode 100644 index 000000000..7c6d79a4e --- /dev/null +++ b/addons/auth/addon/authenticators/password.js @@ -0,0 +1,87 @@ +import BaseAuthenticator from 'ember-simple-auth/authenticators/base'; +import { resolve, reject } from 'rsvp'; +import fetch from 'fetch'; + +/** + * + * This authenticator is intended for use with Ember Simple Auth. + * + * Simple id/password base authenticator sends credentials to an endpoint + * specified by the `buildAuthEndpointURL`. If the HTTP response code is in the + * success range, authentication resolves. Otherwise it rejects. + * + * Upon session invalidation, deauthentication is attempted at the URL generated + * by `buildDeauthEndpointURL`, but is not guaranteed. + * + * This authenticator should not be used directly because it does not specify + * an `buildAuthEndpointURL` or `buildDeauthEndpointURL` of its own. + * To use, generate an application authenticator in your app + * `authenticators/application.js` and extend this class. + * + * @example + * + * import PasswordAuthenticator from 'auth/authenticators/password'; + * export default class ApplicationAuthenticator extends PasswordAuthenticator { + * authEndpoint = '/api/authenticate'; + * deauthEndpoint = '/api/deauthenticate'; + * } + * + */ +export default class PasswordAuthenticator extends BaseAuthenticator { + // =methods + + /** + * Restores the session if data is present. Otherwise rejects. + * @override + * @param {object} data + * @return {Promise} + */ + restore(data) { + return data ? resolve(data) : reject(); + } + + /** + * Posts credentials to the URL specified in `authEndpoint` and resolves + * if a success HTTP status code is received, otherwise rejects. + * + * If requesting cookies, be sure to use the cookie session store. Otherwise + * use the local storage session store. + * + * @override + * @param {object} creds + * @param {string} creds.identification is sent to server as `name` + * @param {string} creds.password + * @param {boolean} requestCookies request cookie tokens (default `true`) + * @return {Promise} + */ + async authenticate( + { identification: name, password }, + requestCookies = true, + options + ) { + const body = JSON.stringify({ + token_type: requestCookies ? 'cookie' : null, + credentials: { name, password }, + }); + const authEndpointURL = this.buildAuthEndpointURL(options); + const response = await fetch(authEndpointURL, { method: 'post', body }); + const json = await response.json(); + return response.status < 400 ? resolve(json) : reject(); + } + + /** + * Posts to the `deauthEntpoint` on a best-effort basis and then returns. + * Deauthentication with the server is not guaranteed and request failures + * are ignored. + * + * @override + * @return {Promise} + */ + invalidate(options) { + const deauthEndpointURL = this.buildDeauthEndpointURL(options); + fetch(deauthEndpointURL, { method: 'post' }).catch(() => { + /* no op */ + }); + return super.invalidate(...arguments); + } +} diff --git a/addons/auth/addon/session-stores/cookie.js b/addons/auth/addon/session-stores/cookie.js new file mode 100644 index 000000000..ea0501ad5 --- /dev/null +++ b/addons/auth/addon/session-stores/cookie.js @@ -0,0 +1,134 @@ +import BaseSessionStore from 'ember-simple-auth/session-stores/base'; +import { resolve, reject } from 'rsvp'; +import { get } from '@ember/object'; +import { inject as service } from '@ember/service'; + +/** + * + * This session store is intended for use with Ember Simple Auth when requesting + * that the server send cookies (see authenticators). + * + * The cookie session store is used to maintain session state for auth servers + * that send cookies. In this strategy, the server sets any cookies needed for + * future authorization. This store determines if a session is authenticated + * by looking for the presence of a cookie with name specified in `cookieName`. + * + * This session store should not be used directly because it does not specify + * `cookiePath` or `cookieName`. To use, create an application session store + * and extend this class, being sure to specify these attributes. + * + * @example + * + * import CookieSessionStore from 'auth/session-stores/cookie'; + * export default class ApplicationSessionStore extends CookieSessionStore { + * cookiePath = '/'; + * cookieName = 'auth-token'; + * } + * + */ +export default class CookieSessionStore extends BaseSessionStore { + // =services + + @service cookies; + + // =attributes + + /** + * The name of the cookie that stores the name of the authenticator, derived + * from the name of the session cookie. The authenticator name is used + * internally by Ember Simple Auth, since it supports simultaneous + * authentication schemes within a single application. + * @type {string} + */ + get authenticatorCookieName() { + return `${this.cookieName}-authenticator`; + } + + /** + * Name of the authenticator used, if any. + * @type {?string} + */ + get authenticatorName() { + return this.cookies.read(this.authenticatorCookieName); + } + + /** + * True if both session and authenticator cookies are present. + * False otherwise. + * @type {boolean} + */ + get sessionExists() { + const tokenExists = this.cookies.exists(this.cookieName); + const authenticatorExists = this.cookies.exists( + this.authenticatorCookieName + ); + return tokenExists && authenticatorExists; + } + + // =methods + + /** + * This method is called automatically on initialization to perform cleanup + * if necessary. + * + * Sessions exist only if both the session and authenticator cookies are + * present. If either one is missing, we want to cleanup to be sure they + * are both absent. + */ + init() { + if (!this.sessionExists) this.clear(); + } + + /** + * While the primary session cookie is persisted by the server, not the + * application, we still need to "remember" the name of the authenticator + * used to authenticate to properly restore a session. Thus when this method + * is called with an authenticator name, we save it into a cookie with name + * `authenticatorCookieName`. If this method is called without an + * authenticator, all related cookies are cleared. + * @param {object} data + * @return {Promise} + */ + persist(data) { + const authenticator = get(data, 'authenticated.authenticator'); + const path = this.cookiePath; + if (authenticator) { + this.cookies.write(this.authenticatorCookieName, authenticator, { path }); + } else { + this.clear(); + } + return resolve(); + } + + /** + * If a session exists (as reported by `sessionExists`), then this method + * resolves with the authentication object expected by Ember Simple Auth, + * which must specify the name of the authenticator used to authenticate. + * + * If the session does not exist, all related cookies are cleared and this + * method rejects. + * + * @return {Promise} + */ + restore() { + if (this.sessionExists) { + return resolve({ + authenticated: { + authenticator: this.authenticatorName, + }, + }); + } + this.clear(); + return reject(); + } + + /** + * Clears both cookies related to the session: the server specified session + * cookie as well as the authenticator name cookie. + */ + clear() { + const path = this.cookiePath; + this.cookies.clear(this.cookieName, { path }); + this.cookies.clear(this.authenticatorCookieName, { path }); + } +} diff --git a/addons/auth/addon/session-stores/local-storage.js b/addons/auth/addon/session-stores/local-storage.js new file mode 100644 index 000000000..7c216fa55 --- /dev/null +++ b/addons/auth/addon/session-stores/local-storage.js @@ -0,0 +1,20 @@ +import LocalStorageSessionStore from 'ember-simple-auth/session-stores/local-storage'; + +/** + * + * This session store is intended for use with Ember Simple Auth when requesting + * that the server send a full token (e.g. cookies are not requested, + * see authenticators). + * + * NOTE: when using a local storage scheme, authorization is not provided + * automatically. In order to achieve authorization... TODO + * + * @example + * + * import LocalStorageSessionStore from 'auth/session-stores/local-storage'; + * export default class ApplicationSessionStore extends LocalStorageSessionStore { + * + * } + * + */ +export default class AuthLocalStorageSessionStore extends LocalStorageSessionStore {} diff --git a/addons/auth/package.json b/addons/auth/package.json index cdd1f5623..33199987a 100644 --- a/addons/auth/package.json +++ b/addons/auth/package.json @@ -33,16 +33,20 @@ "ember-cli-dependency-checker": "^3.2.0", "ember-cli-eslint": "^5.1.0", "ember-cli-inject-live-reload": "^2.0.2", + "ember-cli-mirage": "^1.1.7", "ember-cli-sri": "^2.1.1", "ember-cli-template-lint": "^1.0.0-beta.3", "ember-cli-uglify": "^3.0.0", + "ember-cookies": "^0.5.2", "ember-data": "^3.16.0", "ember-disable-prototype-extensions": "^1.1.3", "ember-export-application-global": "^2.0.1", + "ember-fetch": "^8.0.1", "ember-load-initializers": "^2.1.1", "ember-maybe-import-regenerator": "^0.1.6", "ember-qunit": "^4.6.0", "ember-resolver": "^7.0.0", + "ember-simple-auth": "^3.0.0", "ember-source": "~3.16.0", "ember-source-channel-url": "^2.0.1", "ember-try": "^1.4.0", diff --git a/addons/auth/tests/dummy/app/authenticators/password.js b/addons/auth/tests/dummy/app/authenticators/password.js new file mode 100644 index 000000000..e259039a3 --- /dev/null +++ b/addons/auth/tests/dummy/app/authenticators/password.js @@ -0,0 +1,13 @@ +import PasswordAuthenticator from 'auth/authenticators/password'; + +export default class ApplicationPasswordAuthenticator extends PasswordAuthenticator { + + buildAuthEndpointURL() { + return '/authenticate'; + } + + buildDeauthEndpointURL() { + return '/deauthenticate'; + } + +} diff --git a/addons/auth/tests/dummy/app/session-stores/cookie.js b/addons/auth/tests/dummy/app/session-stores/cookie.js new file mode 100644 index 000000000..50c1a4441 --- /dev/null +++ b/addons/auth/tests/dummy/app/session-stores/cookie.js @@ -0,0 +1,6 @@ +import CookieSessionStore from 'auth/session-stores/cookie'; + +export default class ApplicationCookieSessionStore extends CookieSessionStore { + cookiePath = '/'; + cookieName = 'my-cookie'; +} diff --git a/addons/auth/tests/dummy/app/session-stores/local-storage.js b/addons/auth/tests/dummy/app/session-stores/local-storage.js new file mode 100644 index 000000000..b7546d920 --- /dev/null +++ b/addons/auth/tests/dummy/app/session-stores/local-storage.js @@ -0,0 +1,3 @@ +import LocalStorageSessionStore from 'auth/session-stores/local-storage'; + +export default class ApplicationLocalStorageSessionStore extends LocalStorageSessionStore {} diff --git a/addons/auth/tests/dummy/mirage/config.js b/addons/auth/tests/dummy/mirage/config.js new file mode 100644 index 000000000..3e74fe7c0 --- /dev/null +++ b/addons/auth/tests/dummy/mirage/config.js @@ -0,0 +1,22 @@ +export default function () { + // These comments are here to help you get started. Feel free to delete them. + /* + Config (with defaults). + + Note: these only affect routes defined *after* them! + */ + // this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server + // this.namespace = ''; // make this `/api`, for example, if your API is namespaced + // this.timing = 400; // delay for each request, automatically set to 0 during testing + /* + Shorthand cheatsheet: + + this.get('/posts'); + this.post('/posts'); + this.get('/posts/:id'); + this.put('/posts/:id'); // or this.patch + this.del('/posts/:id'); + + https://www.ember-cli-mirage.com/docs/route-handlers/shorthands + */ +} diff --git a/addons/auth/tests/dummy/mirage/scenarios/default.js b/addons/auth/tests/dummy/mirage/scenarios/default.js new file mode 100644 index 000000000..d388296b5 --- /dev/null +++ b/addons/auth/tests/dummy/mirage/scenarios/default.js @@ -0,0 +1,7 @@ +export default function (/* server */) { + /* + Seed your development database using your factories. + This data will not be loaded in your tests. + */ + // server.createList('post', 10); +} diff --git a/addons/auth/tests/dummy/mirage/serializers/application.js b/addons/auth/tests/dummy/mirage/serializers/application.js new file mode 100644 index 000000000..6224ab8c9 --- /dev/null +++ b/addons/auth/tests/dummy/mirage/serializers/application.js @@ -0,0 +1,3 @@ +import { JSONAPISerializer } from 'ember-cli-mirage'; + +export default JSONAPISerializer.extend({}); diff --git a/addons/auth/tests/unit/authenticators/password-test.js b/addons/auth/tests/unit/authenticators/password-test.js new file mode 100644 index 000000000..aebdfee56 --- /dev/null +++ b/addons/auth/tests/unit/authenticators/password-test.js @@ -0,0 +1,90 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { Response } from 'miragejs'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; + +module('Unit | Authenticator | password', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + test('it authenticates to the specified authEndpoint', async function (assert) { + assert.expect(2); + const authenticator = this.owner.lookup('authenticator:password'); + this.server.post(authenticator.buildAuthEndpointURL(), (schema, request) => { + const json = JSON.parse(request.requestBody); + assert.ok(json.token_type, 'Requested token cookies by default'); + return new Response(200); + }); + await authenticator.authenticate({}).then(() => { + assert.ok(true, 'authentication succeeded'); + }); + }); + + test('it can authenticate without requesting cookies', async function (assert) { + assert.expect(2); + const authenticator = this.owner.lookup('authenticator:password'); + this.server.post(authenticator.buildAuthEndpointURL(), (schema, request) => { + const json = JSON.parse(request.requestBody); + assert.notOk(json.token_type, 'Did not request tokens cookies'); + return new Response(200); + }); + await authenticator.authenticate({}, false).then(() => { + assert.ok(true, 'authentication succeeded'); + }); + }); + + test('it authenticates with the expected payload', async function (assert) { + assert.expect(1); + const authenticator = this.owner.lookup('authenticator:password'); + this.server.post(authenticator.buildAuthEndpointURL(), (schema, request) => { + const json = JSON.parse(request.requestBody); + assert.deepEqual(json, { + token_type: 'cookie', + credentials: { + name: 'foo', + password: 'bar', + }, + }); + return new Response(200); + }); + const creds = { + identification: 'foo', + password: 'bar', + }; + await authenticator.authenticate(creds); + }); + + test('it rejects if the endpoint sends an error status code', async function (assert) { + assert.expect(1); + const authenticator = this.owner.lookup('authenticator:password'); + this.server.post(authenticator.buildAuthEndpointURL(), () => { + return new Response(400); + }); + await authenticator.authenticate({}).catch(() => { + assert.ok(true, 'authentication failed'); + }); + }); + + test('it deauthenticates on invalidation', async function (assert) { + assert.expect(1); + const authenticator = this.owner.lookup('authenticator:password'); + this.server.post(authenticator.buildDeauthEndpointURL(), () => { + assert.ok(true, 'deauthentication occurred'); + return new Response(200); + }); + await authenticator.invalidate(); + }); + + test('it invalidation succeeds even if the deauthentication request fails', async function (assert) { + assert.expect(2); + const authenticator = this.owner.lookup('authenticator:password'); + this.server.post(authenticator.buildDeauthEndpointURL(), () => { + assert.ok(true, 'deauthentication was requested'); + return new Response(500); + }); + await authenticator + .invalidate() + .then(() => assert.ok(true)) + .catch(() => assert.notOk(true, 'uh oh, this should not happen')); + }); +}); diff --git a/addons/auth/tests/unit/session-stores/cookie-test.js b/addons/auth/tests/unit/session-stores/cookie-test.js new file mode 100644 index 000000000..464ebab52 --- /dev/null +++ b/addons/auth/tests/unit/session-stores/cookie-test.js @@ -0,0 +1,92 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Session Store | cookie', function (hooks) { + setupTest(hooks); + + let sessionStore; + + hooks.beforeEach(function () { + sessionStore = this.owner.lookup('session-store:cookie'); + sessionStore.clear(); + }); + + hooks.afterEach(function () { + sessionStore.clear(); + }); + + test('it restores a session if both necessary cookies are present (session exists)', async function (assert) { + assert.expect(2); + sessionStore.cookies.write(sessionStore.cookieName, 'token-value', { + path: '/', + }); + sessionStore.cookies.write( + sessionStore.authenticatorCookieName, + 'authenticator-name', + { path: '/' } + ); + assert.ok(sessionStore.sessionExists); + const session = await sessionStore.restore(); + assert.deepEqual(session, { + authenticated: { + authenticator: 'authenticator-name', + }, + }); + }); + + test('it rejects restore if the session does not exist', async function (assert) { + assert.expect(2); + assert.notOk(sessionStore.sessionExists); + await sessionStore.restore().catch(() => {}); + assert.ok(true, 'Rejected'); + }); + + test('it cleans up cookies on restore rejection', async function (assert) { + assert.expect(4); + // For session cookie + sessionStore.cookies.write(sessionStore.cookieName, 'token-value', { + path: '/', + }); + assert.ok( + sessionStore.cookies.exists(sessionStore.cookieName), + 'cookie exists' + ); + await sessionStore.restore().catch(() => {}); + assert.notOk( + sessionStore.cookies.exists(sessionStore.cookieName), + 'cookie was cleared' + ); + // For authenticator name cookie + sessionStore.cookies.write( + sessionStore.authenticatorCookieName, + 'authenticator-name', + { path: '/' } + ); + assert.ok( + sessionStore.cookies.exists(sessionStore.authenticatorCookieName), + 'cookie exists' + ); + await sessionStore.restore().catch(() => {}); + assert.notOk( + sessionStore.cookies.exists(sessionStore.authenticatorCookieName), + 'cookie was cleared' + ); + }); + + test('it persists only an authenticator name cookie, since the server persists the session cookie', async function (assert) { + assert.expect(4); + assert.notOk(sessionStore.cookies.exists(sessionStore.cookieName)); + assert.notOk( + sessionStore.cookies.exists(sessionStore.authenticatorCookieName) + ); + sessionStore.persist({ authenticated: { authenticator: 'foobar' } }); + assert.notOk( + sessionStore.cookies.exists(sessionStore.cookieName), + 'session cookie was not set' + ); + assert.equal( + sessionStore.cookies.read(sessionStore.authenticatorCookieName), + 'foobar' + ); + }); +}); diff --git a/addons/rose/addon/styles/rose/components/form/_input.scss b/addons/rose/addon/styles/rose/components/form/_input.scss index 6c0186b50..7b11a0a8e 100644 --- a/addons/rose/addon/styles/rose/components/form/_input.scss +++ b/addons/rose/addon/styles/rose/components/form/_input.scss @@ -19,6 +19,7 @@ .rose-form-input-field { @include type.type(m); + box-sizing: border-box; margin-bottom: sizing.rems(xs); padding: sizing.rems(xs); color: var(--stark); diff --git a/addons/rose/addon/styles/rose/variables/_media.scss b/addons/rose/addon/styles/rose/variables/_media.scss index 289c96ad9..95d1ab86d 100644 --- a/addons/rose/addon/styles/rose/variables/_media.scss +++ b/addons/rose/addon/styles/rose/variables/_media.scss @@ -9,7 +9,7 @@ $width-breakpoints: ( xlarge: 1200px, large: 1024px, medium: 768px, - small: 480px, + small: 540px, xsmall: 320px, ); diff --git a/package.json b/package.json index e455ef34b..f07bec9d6 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "lint:addons:rose": "echo \"\n\nlint: addons/rose\n\" && cd addons/rose && yarn lint", "lint:ui:core": "echo \"\n\nlint: ui/core\n\" && cd ui/core && yarn lint", "lint:ui:connect-client": "echo \"\n\nlint: ui/connect-client\n\" && cd ui/connect-client && yarn lint", - "test": "yarn test:addons:api && yarn test:addons:auth && yarn test:addons:rose && yarn test:ui:core && yarn test:ui:connect-client", + "test": "yarn test:addons:api && yarn test:addons:auth && yarn test:addons:rose && yarn test:ui:core", "test:addons:api": "echo \"\n\ntest: addons/api\n\" && cd addons/api && yarn test", "test:addons:auth": "echo \"\n\ntest: addons/auth\n\" && cd addons/auth && yarn test", "test:addons:rose": "echo \"\n\ntest: addons/rose\n\" && cd addons/rose && yarn test", diff --git a/ui/core/app/authenticators/password.js b/ui/core/app/authenticators/password.js new file mode 100644 index 000000000..41e75d078 --- /dev/null +++ b/ui/core/app/authenticators/password.js @@ -0,0 +1,47 @@ +import BasePasswordAuthenticator from 'auth/authenticators/password'; +import { inject as service } from '@ember/service'; + +/** + * A username/password authenticator that authenticates with + * a scope/auth-method endpoint. Authentication occurs on an auth method + * endpoint, while deauthentication occurs on a scope endpoint. + */ +export default class PasswordAuthenticator extends BasePasswordAuthenticator { + + // =services + + @service store; + + // =attributes + + /** + * Generates an auth method URL with which to authenticate. + * @override + * @param {object} options + * @param {string} options.scope.scope_id + * @param {string} options.authMethod.id + * @return {string} + */ + buildAuthEndpointURL({ + scope: { id: scopeID }, + authMethod: { id: authMethodID } + }) { + const adapter = this.store.adapterFor('application'); + const options = { adapterOptions: { scopeID, method: 'authenticate' } }; + return adapter.buildURL('auth-method', authMethodID, options, 'findRecord'); + } + + /** + * Generates a scope URL with which to deauthenticate. + * @override + * @param {object} options + * @param {string} scopeID + * @return {string} + */ + buildDeauthEndpointURL({ org_id: scopeID }) { + const adapter = this.store.adapterFor('application'); + const options = { adapterOptions: { method: 'deauthenticate' } }; + return adapter.buildURL('scope', scopeID, options, 'findRecord'); + } + +} diff --git a/ui/core/app/controllers/application.js b/ui/core/app/controllers/application.js new file mode 100644 index 000000000..3fd811d62 --- /dev/null +++ b/ui/core/app/controllers/application.js @@ -0,0 +1,10 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ApplicationController extends Controller { + + // =services + + @service session; + +} diff --git a/ui/core/app/controllers/scopes/scope.js b/ui/core/app/controllers/scopes/scope.js new file mode 100644 index 000000000..5bc636752 --- /dev/null +++ b/ui/core/app/controllers/scopes/scope.js @@ -0,0 +1,10 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; + +export default class ScopesScopeController extends Controller { + + // =services + + @service session; + +} diff --git a/ui/core/app/router.js b/ui/core/app/router.js index e07a9ee94..ef3536c60 100644 --- a/ui/core/app/router.js +++ b/ui/core/app/router.js @@ -10,6 +10,9 @@ Router.map(function () { this.route('scopes', function() { this.route('scope', { path: ':scope_id' }, function() { + this.route('authenticate', function() { + this.route('method', { path: ':auth_method_id'}); + }); this.route('projects', function() { this.route('project', { path: ':project_id' }, function() {}); this.route('new'); diff --git a/ui/core/app/routes/application.js b/ui/core/app/routes/application.js index 6cc5ca574..024a0b887 100644 --- a/ui/core/app/routes/application.js +++ b/ui/core/app/routes/application.js @@ -1,6 +1,63 @@ import Route from '@ember/routing/route'; +import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin'; +import Ember from 'ember'; +import { getOwner } from '@ember/application'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { later } from '@ember/runloop'; /** * Entry route for the application. */ -export default class ApplicationRoute extends Route {} +export default class ApplicationRoute extends Route.extend( + ApplicationRouteMixin +) { + + // =services + + @service session; + + // =attributes + + routeIfUnauthenticated = 'index'; + + // =methods + + /** + * After becoming authenticated, does nothing. This overrides the default + * behavior of the ApplicationRouteMixin, which is to redirect after auth. + * We'll handle this redirect manually in sub routes. + * @override + */ + sessionAuthenticated() { + // no op + } + + /** + * When the session ends, redirect to authenticate and reload the page to + * purge any in-memory state. + */ + async sessionInvalidated() { + // Catch error in this transition, since it will be aborted by the + // scope auth route when it redirects to the first auth method. + await this.transitionTo(this.routeIfUnauthenticated).catch(() => {}); + // The Ember way of accessing globals... + const document = getOwner(this).lookup('service:-document').documentElement; + // defaultView === window, but without using globals directly + const { location } = document.parentNode.defaultView; + // Wait a beat, then reload the page... + // This is mostly to give the deauth request a chance to fire. + /* istanbul ignore if */ + if (!Ember.testing) later(location, location.reload, 150); + } + + // =actions + + /** + * Delegates invalidation to the session service. + */ + @action + invalidateSession() { + this.session.invalidate(); + } +} diff --git a/ui/core/app/routes/index.js b/ui/core/app/routes/index.js new file mode 100644 index 000000000..4ec1a62b0 --- /dev/null +++ b/ui/core/app/routes/index.js @@ -0,0 +1,14 @@ +import Route from '@ember/routing/route'; + +export default class IndexRoute extends Route { + + // =methods + + /** + * Redirects to scopes route for further processing. + */ + redirect() { + this.transitionTo('scopes'); + } + +} diff --git a/ui/core/app/routes/scopes.js b/ui/core/app/routes/scopes.js index 5fff74b2e..a849878ba 100644 --- a/ui/core/app/routes/scopes.js +++ b/ui/core/app/routes/scopes.js @@ -1,4 +1,24 @@ import Route from '@ember/routing/route'; +import { A } from '@ember/array'; export default class ScopesRoute extends Route { + + // =methods + + /** + * Attempt to load the specified scope from the API. This is allowed + * to fail, since in some cases the user may not have permission to read a + * scope directly, but may have permission to read resources under it. + * If the scope fails to load, we still proceed using a temporary scope object + * consisting of only the specified ID. + * @param {object} params + * @param {string} params.scope_id + * @return {Promise{ScopeModel}} + */ + model() { + // NOTE: In the absence of a `scope_id` query parameter, this endpoint is + // expected to default to the global scope, thus returning org scopes. + return this.store.findAll('scope').catch(() => A([])); + } + } diff --git a/ui/core/app/routes/scopes/index.js b/ui/core/app/routes/scopes/index.js new file mode 100644 index 000000000..06a263171 --- /dev/null +++ b/ui/core/app/routes/scopes/index.js @@ -0,0 +1,29 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; + +export default class ScopesIndexRoute extends Route { + + // =services + + @service session; + + // =methods + + /** + * If authenticated, redirects to the scope route of the authenticted scope. + * If unauthenticated, redirects to the first scope that was loaded (if any). + * @param {[ScopeModel]} model + * @param {?ScopeModel} model.firstObject + */ + redirect({ firstObject }) { + const authenticatedScopeID = + get(this.session, 'data.authenticated.scope.id'); + if (authenticatedScopeID) { + this.transitionTo('scopes.scope', authenticatedScopeID); + } else if (firstObject) { + this.transitionTo('scopes.scope', firstObject.id); + } + } + +} diff --git a/ui/core/app/routes/scopes/scope.js b/ui/core/app/routes/scopes/scope.js index 8cde487bd..939ef5076 100644 --- a/ui/core/app/routes/scopes/scope.js +++ b/ui/core/app/routes/scopes/scope.js @@ -14,15 +14,13 @@ export default class ScopesScopeRoute extends Route { * @param {string} params.scope_id * @return {Promise{ScopeModel}} */ - async model({ scope_id: id }) { - try { - return await this.store.findRecord('scope', id); - } catch { - const maybeExistingScope = this.store.peekRecord('scope', id); - // TODO it's unclear if creating a temporary unsaved scope is reliable... - // we may need to revert to a POJO. - return maybeExistingScope || this.store.createRecord('scope', { id }); - } + model({ scope_id: id }) { + return this.store.findRecord('scope', id) + .catch(() => { + const maybeExistingScope = this.store.peekRecord('scope', id); + // TODO it's unclear if creating a temporary unsaved scope is reliable + // we may need to revert to a POJO. + return maybeExistingScope || this.store.createRecord('scope', { id }); + }); } - } diff --git a/ui/core/app/routes/scopes/scope/authenticate.js b/ui/core/app/routes/scopes/scope/authenticate.js new file mode 100644 index 000000000..93f565baa --- /dev/null +++ b/ui/core/app/routes/scopes/scope/authenticate.js @@ -0,0 +1,45 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { hash } from 'rsvp'; + +export default class ScopesScopeAuthenticateRoute extends Route { + + // =services + + @service session; + + // =attributes + + // TODO: this assumes we're in an org scope, but the "landing" + // for global scope is probably different. + routeIfAlreadyAuthenticated = 'scopes.scope.projects'; + + // =methods + + /** + * Returns all auth methods for the current scope, along with the current + * scope and all scopes (for org navigation). + * @return {Promise} `{scope, scopes, authMethods}` + */ + model() { + const adapterOptions = { scopeID: this.modelFor('scopes.scope').id }; + return hash({ + scope: this.modelFor('scopes.scope'), + scopes: this.modelFor('scopes'), + authMethods: this.store.findAll('auth-method', { adapterOptions }), + + // for integration testing: + // authMethods: A([{ + // id: 'am_1234567890', + // type: 'password' + // }]) + }); + } + + redirect() { + if (this.session.isAuthenticated) { + this.transitionTo(this.routeIfAlreadyAuthenticated); + } + } + +} diff --git a/ui/core/app/routes/scopes/scope/authenticate/index.js b/ui/core/app/routes/scopes/scope/authenticate/index.js new file mode 100644 index 000000000..5ee523b46 --- /dev/null +++ b/ui/core/app/routes/scopes/scope/authenticate/index.js @@ -0,0 +1,20 @@ +import Route from '@ember/routing/route'; + +export default class ScopesScopeAuthenticateIndexRoute extends Route { + + // =methods + + /** + * This index route exists solely to redirect the user on to the + * first auth method. + * @param {object} model + * @param {[AuthMethodModel]} model.authMethods + */ + redirect({ authMethods }) { + const firstAuthMethod = authMethods.firstObject; + if (firstAuthMethod) { + this.transitionTo('scopes.scope.authenticate.method', firstAuthMethod); + } + } + +} diff --git a/ui/core/app/routes/scopes/scope/authenticate/method.js b/ui/core/app/routes/scopes/scope/authenticate/method.js new file mode 100644 index 000000000..b52406722 --- /dev/null +++ b/ui/core/app/routes/scopes/scope/authenticate/method.js @@ -0,0 +1,66 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class ScopesScopeAuthenticateMethodRoute extends Route { + + // =services + + @service session; + @service notify; + @service intl; + + // =attributes + + routeAfterAuthentication = 'scopes.scope.projects'; + + // =methods + + /** + * Returns an auth method by id. + * @param {object} params + * @return {Promise{AuthMethodModel}} + */ + model({ auth_method_id: id }) { + const adapterOptions = { scopeID: this.modelFor('scopes.scope').id }; + return this.store.findRecord('auth-method', id, adapterOptions); + } + + /** + * Resets the password field on the controller so it is not + * inadvertantly retained in memory. + */ + resetCredentials() { + this.controller.setProperties({ + password: null, + }); + } + + // =actions + + /** + * Delegates authentication to the `password` authenticator. + * If authentication fails, notifies with an alert. + */ + @action + async authenticate(creds) { + this.resetCredentials(); + const authenticatorName = 'authenticator:password'; + const scope = this.modelFor('scopes.scope'); + const authMethod = this.currentModel; + const requestCookies = false; + try { + await this.session.authenticate( + authenticatorName, + creds, + requestCookies, + { scope, authMethod } + ); + this.transitionTo(this.routeAfterAuthentication); + } catch (e) { + const errorMessage = this.intl.t('errors.titles.authentication-failed'); + this.notify.error(errorMessage, { closeAfter: null }); + } + } + +} diff --git a/ui/core/app/routes/scopes/scope/index.js b/ui/core/app/routes/scopes/scope/index.js new file mode 100644 index 000000000..0215ea9da --- /dev/null +++ b/ui/core/app/routes/scopes/scope/index.js @@ -0,0 +1,14 @@ +import Route from '@ember/routing/route'; + +export default class ScopesScopeIndexRoute extends Route { + + // =methods + + /** + * Redirects to scopes/scope/authenticate for further processing. + */ + redirect() { + this.transitionTo('scopes.scope.authenticate'); + } + +} diff --git a/ui/core/app/routes/scopes/scope/projects.js b/ui/core/app/routes/scopes/scope/projects.js index ebc5e97e5..a7ab28af6 100644 --- a/ui/core/app/routes/scopes/scope/projects.js +++ b/ui/core/app/routes/scopes/scope/projects.js @@ -7,9 +7,17 @@ export default class ScopesScopeProjectsRoute extends Route { // =services @service notify; + @service session; // =methods + /** + * If arriving here unauthenticated, redirect to index for further processing. + */ + beforeModel() { + if (!this.session.isAuthenticated) this.transitionTo('index'); + } + /** * Loads all scopes under the current scope. * @return {Promise{[ScopeModel]}} diff --git a/ui/core/app/session-stores/application.js b/ui/core/app/session-stores/application.js new file mode 100644 index 000000000..80d0603db --- /dev/null +++ b/ui/core/app/session-stores/application.js @@ -0,0 +1,64 @@ +import LocalStorageSessionStore from 'auth/session-stores/local-storage'; +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; +//import config from '../config/environment'; + +export default class ApplicationSessionStore extends LocalStorageSessionStore { + + @service store; + + /** + * If the auth sessionData includes a token, apply it as a bearer token in the + * application adapter's Authorization header. Otherwise clear the header. + * @param {object} sessionData + * @return {object} + */ + persist(sessionData) { + this.addTokenToAuthorization(sessionData); + this.addScopeToSession(sessionData); + return super.persist(...arguments); + } + + /** + * If the session has a full token, it is added as a bearer token to the + * `Authorization` header for all API requests. + * @param {object} sessionData + */ + addTokenToAuthorization(sessionData) { + const token = get(sessionData, 'authenticated.token'); + const adapter = this.store.adapterFor('application'); + const headers = get(adapter, 'headers'); + if (!headers) adapter.headers = {}; + adapter.headers.Authorization = null; + if (token) adapter.headers.Authorization = `Bearer ${token}`; + } + + /** + * @param {object} sessionData + */ + addScopeToSession(sessionData) { + const scope = get(this, 'scope'); + const authenticated = get(sessionData, 'authenticated'); + if (scope && authenticated) authenticated.scope = scope; + } + +} + +// import CookieSessionStore from 'auth/session-stores/cookie'; +// import config from '../config/environment'; +// +// export default class ApplicationSessionStore extends CookieSessionStore { +// +// // =attributes +// +// /** +// * @type {string} +// */ +// cookiePath = config.rootURL; +// +// /** +// * @type {string} +// */ +// cookieName = config.auth.passwordCookieName; +// +// } diff --git a/ui/core/app/templates/application.hbs b/ui/core/app/templates/application.hbs index fbbecf1e7..0f0b66871 100644 --- a/ui/core/app/templates/application.hbs +++ b/ui/core/app/templates/application.hbs @@ -6,6 +6,8 @@ + + {{!-- {{t 'dev.menu-item'}} @@ -15,16 +17,22 @@ {{t 'dev.section'}} {{t 'dev.section'}} - - - {{t 'dev.menu-item'}} - {{t 'dev.button-menu-item'}} - - + --}} + + {{#if this.session.isAuthenticated}} + + + + {{t 'actions.deauthenticate'}} + + + + {{/if}} + diff --git a/ui/core/app/templates/scopes/index.hbs b/ui/core/app/templates/scopes/index.hbs new file mode 100644 index 000000000..e2147cab0 --- /dev/null +++ b/ui/core/app/templates/scopes/index.hbs @@ -0,0 +1 @@ +{{outlet}} \ No newline at end of file diff --git a/ui/core/app/templates/scopes/scope.hbs b/ui/core/app/templates/scopes/scope.hbs index 2d1a73607..172eefc48 100644 --- a/ui/core/app/templates/scopes/scope.hbs +++ b/ui/core/app/templates/scopes/scope.hbs @@ -1,32 +1,33 @@ - + {{#if this.session.isAuthenticated}} + + + + {{t "resources.projects"}} + + - - - {{t "resources.projects"}} - - + {{outlet "sidebar"}} - {{outlet "sidebar"}} - - {{!-- - - - {{t "resources.users"}} - - - {{t "resources.groups"}} - + {{!-- + + + {{t "resources.users"}} + - - {{t "resources.roles"}} - - - --}} + + {{t "resources.groups"}} + - + + {{t "resources.roles"}} + + + --}} + + {{/if}}
diff --git a/ui/core/app/templates/scopes/scope/authenticate.hbs b/ui/core/app/templates/scopes/scope/authenticate.hbs new file mode 100644 index 000000000..43b329188 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/authenticate.hbs @@ -0,0 +1,54 @@ +{{title (t "actions.authenticate")}} + + + + + +

{{t "actions.authenticate"}}

+

{{t "auth.descriptions.authenticating-with-org" name=@model.scope.displayName}}

+
+ + + + + {{#each @model.scopes as |scope|}} + + {{scope.displayName}} + + {{/each}} + + + + + + {{#each @model.authMethods as |authMethod|}} + + {{t (concat "auth.method-types." authMethod.type)}} + + {{/each}} + + + + + {{#if @model.authMethods}} + {{outlet}} + {{/if}} + {{#unless @model.authMethods}} + + + + {{t "auth.descriptions.no-methods"}} + + + + {{/unless}} + + +
+
diff --git a/ui/core/app/templates/scopes/scope/authenticate/index.hbs b/ui/core/app/templates/scopes/scope/authenticate/index.hbs new file mode 100644 index 000000000..22cc46f4e --- /dev/null +++ b/ui/core/app/templates/scopes/scope/authenticate/index.hbs @@ -0,0 +1,12 @@ +{{#unless @model}} + + + + {{t "auth.descriptions.no-methods"}} + + + +{{/unless}} diff --git a/ui/core/app/templates/scopes/scope/authenticate/method.hbs b/ui/core/app/templates/scopes/scope/authenticate/method.hbs new file mode 100644 index 000000000..60057d773 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/authenticate/method.hbs @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/ui/core/app/templates/scopes/scope/index.hbs b/ui/core/app/templates/scopes/scope/index.hbs new file mode 100644 index 000000000..c24cd6895 --- /dev/null +++ b/ui/core/app/templates/scopes/scope/index.hbs @@ -0,0 +1 @@ +{{outlet}} diff --git a/ui/core/mirage/config.js b/ui/core/mirage/config.js index ed6859c83..fec96c052 100644 --- a/ui/core/mirage/config.js +++ b/ui/core/mirage/config.js @@ -1,5 +1,6 @@ import config from '../config/environment'; -import { Response } from 'miragejs'; +import { authHandler, deauthHandler } from './route-handlers/auth'; +//import { Response } from 'miragejs'; export default function() { @@ -20,9 +21,10 @@ export default function() { this.get('/scopes', ({ scopes }, { queryParams: { scope_id } }) => { // Default parent scope is global if (!scope_id) scope_id = 'global'; - return scopes.where(scope => { + const results = scopes.where(scope => { return scope.scope ? scope.scope.id === scope_id : false }); + return results; }); this.post('/scopes', function ({ scopes }, request) { // Parent scope comes through the payload via `scope_id`, but this needs @@ -33,17 +35,7 @@ export default function() { attrs.scope = parentScopeAttrs; return scopes.create(attrs); }); - // To simulate a possible real-world case, org scopes are not returned, - // but project scopes are. - // TODO this should be expanded to support other scenarios. - this.get('/scopes/:id', ({ scopes }, { params: { id: scopeID } }) => { - const scope = scopes.find(scopeID); - if (scope.type !== 'project') { - return new Response(404, {}, {}); - } else { - return scope; - } - }); + this.get('/scopes/:id'); this.patch('/scopes/:id'); this.del('/scopes/:id'); @@ -55,6 +47,10 @@ export default function() { this.patch('/scopes/:scope_id/auth-methods/:id'); this.del('/scopes/:scope_id/auth-methods/:id'); + // Authenticate/deauthenticate routes + this.post('/scopes/:scope_id/auth-methods/:id_method', authHandler); + this.post('/scopes/:id_method', deauthHandler); + // IAM : Users this.get('/scopes/:scope_id/users'); this.post('/scopes/:scope_id/users'); diff --git a/ui/core/mirage/route-handlers/auth.js b/ui/core/mirage/route-handlers/auth.js new file mode 100644 index 000000000..596500f46 --- /dev/null +++ b/ui/core/mirage/route-handlers/auth.js @@ -0,0 +1,50 @@ +// import config from '../../config/environment'; +// import { v1 } from 'ember-uuid'; +import { Response } from 'miragejs'; + +// /** +// * Simulates a cookie-based response to authentication requests. Any user/pass +// * values are allowed *except* when `username` equals `error`, in which case a +// * simulated error response is returned. +// * @param {object} schema +// * @param {object} request +// * @return {Response} +// */ +// export default function authenticateHandler(schema, request) { +// const payload = JSON.parse(request.requestBody); +// if (payload.credentials.name === 'error') { +// return new Response(400); +// } else { +// const cookieName = config.auth.passwordCookieName; +// const cookieValue = v1(); +// const cookiePath = config.rootURL; +// document.cookie = `${cookieName}=${cookieValue};path=${cookiePath}`; +// return new Response(200); +// } +// } + +export function authHandler({ scopes }, request) { + const payload = JSON.parse(request.requestBody); + if (payload.credentials.name === 'error') { + return new Response(400); + } else { + const scopeAttrs = + this.serialize(scopes.find(request.params.scope_id)); + const scope = scopeAttrs; + return new Response(200, {}, { + scope, + id: 'token123', + token: 'thetokenstring', + user_id: 'user123', + auth_method_id: 'authmethod123', + created_time: '', + updated_time: '', + last_used_time: '', + expiration_time: '' + }); + } +} + +export function deauthHandler() { + return new Response(200); +} diff --git a/ui/core/mirage/scenarios/default.js b/ui/core/mirage/scenarios/default.js index f143f6ec6..1bbae80d5 100644 --- a/ui/core/mirage/scenarios/default.js +++ b/ui/core/mirage/scenarios/default.js @@ -2,14 +2,31 @@ export default function(server) { // Scope resources - server.create('scope', null, 'withChildren'); + const globalScope = server.create('scope', { id: 'global' }); // creates a global scope + const orgScope = server.create('scope', { + type: 'org', + scope: { id: globalScope.id, type: globalScope.type } + }, 'withChildren'); + + const scope = { id: orgScope.id, type: orgScope.type }; + + // Create additional scopes for authentication organization selection + server.create('scope', { + type: 'org', + scope: { id: globalScope.id, type: globalScope.type } + }, 'withChildren'); + + server.create('scope', { + type: 'org', + scope: { id: globalScope.id, type: globalScope.type } + }, 'withChildren'); // Auth // Auth methods exist both at the global scope and the org scope. // For simplicity we don't scope our mock auth methods and just return the // same items for request at all scopes. - server.createList('auth-method', 3); + server.createList('auth-method', 3, { scope }); // User server.createList('user', 5); diff --git a/ui/core/package.json b/ui/core/package.json index f6ae93043..37cb246e5 100644 --- a/ui/core/package.json +++ b/ui/core/package.json @@ -32,6 +32,7 @@ "@glimmer/component": "^1.0.0", "@glimmer/tracking": "^1.0.0", "api": "*", + "auth": "*", "babel-eslint": "^10.0.3", "babel-loader": "^8.1.0", "broccoli-asset-rev": "^3.0.0", @@ -68,6 +69,7 @@ "ember-qunit": "^4.6.0", "ember-resolver": "^7.0.0", "ember-route-action-helper": "^2.0.8", + "ember-simple-auth": "^3.0.0", "ember-source": "~3.16.0", "ember-uuid": "^2.1.0", "ember-welcome-page": "^4.0.0", diff --git a/ui/core/tests/acceptance/authentication-test.js b/ui/core/tests/acceptance/authentication-test.js new file mode 100644 index 000000000..f6fa1c8ab --- /dev/null +++ b/ui/core/tests/acceptance/authentication-test.js @@ -0,0 +1,128 @@ +import { module, test } from 'qunit'; +import { visit, currentURL, fillIn, click, 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 { + currentSession, + authenticateSession, + invalidateSession, +} from 'ember-simple-auth/test-support'; + +module('Acceptance | authentication', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let indexURL; + let globalScope; + let orgScope; + let orgScopeID; + let scopesURL; + let orgScopeURL; + let scope; + let authMethod; + let authMethodID; + let authenticateURL; + let authMethodAuthenticateURL; + let projectsURL; + + hooks.beforeEach(function () { + invalidateSession(); + indexURL = '/'; + globalScope = this.server.create('scope', { id: 'global' }); + orgScope = this.server.create('scope', { + type: 'org', + scope: { id: globalScope.id, type: globalScope.type } + }, 'withChildren'); + scope = { id: orgScope.id, type: orgScope.type }; + authMethod = this.server.create('auth-method', { scope }); + orgScopeID = orgScope.id; + authMethodID = authMethod.id; + scopesURL = `/scopes`; + orgScopeURL = `/scopes/${orgScopeID}`; + authenticateURL = `/scopes/${orgScopeID}/authenticate`; + authMethodAuthenticateURL = `/scopes/${orgScopeID}/authenticate/${authMethodID}`; + projectsURL = `/scopes/${orgScopeID}/projects`; + }); + + test('visiting auth methods authenticate route redirects to first auth method', async function(assert) { + assert.expect(1); + await visit(authenticateURL); + await a11yAudit(); + assert.equal(currentURL(), authMethodAuthenticateURL); + }); + + test('visiting any authentication parent route while unauthenticated redirects to first authenticate method', async function(assert) { + assert.expect(3); + await visit(indexURL); + assert.equal(currentURL(), authMethodAuthenticateURL); + await visit(scopesURL); + assert.equal(currentURL(), authMethodAuthenticateURL); + assert.notOk(currentSession().isAuthenticated); + }); + + test('visiting projects while unauthenticated redirects to first authenticate method', async function(assert) { + assert.expect(2); + await visit(projectsURL); + assert.equal(currentURL(), authMethodAuthenticateURL); + assert.notOk(currentSession().isAuthenticated); + }); + + test('visiting auth method authenticate route', async function(assert) { + assert.expect(1); + await visit(authMethodAuthenticateURL); + await a11yAudit(); + assert.equal(currentURL(), authMethodAuthenticateURL); + }); + + test('visiting any authentication parent route while already authenticated redirects to projects', async function(assert) { + assert.expect(6); + authenticateSession({ scope }); + await visit(indexURL); + assert.equal(currentURL(), projectsURL); + await visit(scopesURL); + assert.equal(currentURL(), projectsURL); + await visit(orgScopeURL); + assert.equal(currentURL(), projectsURL); + await visit(authenticateURL); + assert.equal(currentURL(), projectsURL); + await visit(authMethodAuthenticateURL); + assert.equal(currentURL(), projectsURL); + assert.ok(currentSession().isAuthenticated); + }); + + test('failed authentication shows a notification message', async function(assert) { + assert.expect(3); + await visit(authMethodAuthenticateURL); + assert.notOk(currentSession().isAuthenticated); + await fillIn('[name="identification"]', 'error'); + await click('[type="submit"]'); + assert.ok(find('.rose-notification.is-error')); + assert.notOk(currentSession().isAuthenticated); + }); + + test('successful authentication redirects to projects', async function(assert) { + assert.expect(4); + await visit(authMethodAuthenticateURL); + assert.notOk(currentSession().isAuthenticated); + assert.equal(currentURL(), authMethodAuthenticateURL); + await fillIn('[name="identification"]', 'test'); + await fillIn('[name="password"]', 'test'); + await click('[type="submit"]'); + assert.equal(currentURL(), projectsURL); + assert.ok(currentSession().isAuthenticated); + }); + + test('deauthentication redirects to first authenticate method', async function(assert) { + assert.expect(3); + await visit(authMethodAuthenticateURL); + await fillIn('[name="identification"]', 'test'); + await fillIn('[name="password"]', 'test'); + await click('[type="submit"]'); + assert.ok(currentSession().isAuthenticated); + await click('.rose-button-header-dropdown'); + assert.notOk(currentSession().isAuthenticated); + assert.equal(currentURL(), authMethodAuthenticateURL); + }); + +}); diff --git a/ui/core/tests/unit/controllers/application-test.js b/ui/core/tests/unit/controllers/application-test.js new file mode 100644 index 000000000..93d19cebe --- /dev/null +++ b/ui/core/tests/unit/controllers/application-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | application', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let controller = this.owner.lookup('controller:application'); + assert.ok(controller); + }); +}); diff --git a/ui/core/tests/unit/controllers/scopes/scope-test.js b/ui/core/tests/unit/controllers/scopes/scope-test.js new file mode 100644 index 000000000..ec2c389bb --- /dev/null +++ b/ui/core/tests/unit/controllers/scopes/scope-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | scopes/scope', function(hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function(assert) { + let controller = this.owner.lookup('controller:scopes/scope'); + assert.ok(controller); + }); +}); diff --git a/ui/core/tests/unit/routes/index-test.js b/ui/core/tests/unit/routes/index-test.js new file mode 100644 index 000000000..eaaac17bc --- /dev/null +++ b/ui/core/tests/unit/routes/index-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | index', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:index'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/index-test.js b/ui/core/tests/unit/routes/scopes/index-test.js new file mode 100644 index 000000000..c2fb98357 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/index-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/index', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/index'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/authenticate-test.js b/ui/core/tests/unit/routes/scopes/scope/authenticate-test.js new file mode 100644 index 000000000..2ee818453 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/authenticate-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/authenticate', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/authenticate'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/authenticate/index-test.js b/ui/core/tests/unit/routes/scopes/scope/authenticate/index-test.js new file mode 100644 index 000000000..88d5a1121 --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/authenticate/index-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/authenticate/index', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/authenticate/index'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/authenticate/method-test.js b/ui/core/tests/unit/routes/scopes/scope/authenticate/method-test.js new file mode 100644 index 000000000..5aed2aefe --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/authenticate/method-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/authenticate/method', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/authenticate/method'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/routes/scopes/scope/index-test.js b/ui/core/tests/unit/routes/scopes/scope/index-test.js new file mode 100644 index 000000000..ba0a5bdba --- /dev/null +++ b/ui/core/tests/unit/routes/scopes/scope/index-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | scopes/scope/index', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:scopes/scope/index'); + assert.ok(route); + }); +}); diff --git a/ui/core/tests/unit/session-stores/application.js b/ui/core/tests/unit/session-stores/application.js new file mode 100644 index 000000000..dd7132709 --- /dev/null +++ b/ui/core/tests/unit/session-stores/application.js @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Session Store | application', function (hooks) { + setupTest(hooks); + + let store; + let applicationAdapter; + let sessionStore; + + hooks.beforeEach(function () { + store = this.owner.lookup('service:store'); + applicationAdapter = store.adapterFor('application'); + sessionStore = this.owner.lookup('session-store:application'); + sessionStore.clear(); + }); + + hooks.afterEach(function () { + sessionStore.clear(); + }); + + test('it does not add an authorization header to application adapter on persist if no token is present', async function (assert) { + assert.expect(1); + sessionStore.persist({ authenticated: { token: null } }); + assert.notOk(applicationAdapter.headers.Authorization); + }); + + test('it adds an authorization header to application adapter on persist', async function (assert) { + assert.expect(1); + sessionStore.persist({ authenticated: { token: 'token1234' } }); + assert.equal(applicationAdapter.headers.Authorization, 'Bearer token1234'); + }); + + test('it adds an authorization header to application adapter which already has headers on persist', async function (assert) { + assert.expect(2); + applicationAdapter.headers = { foo: 'bar' }; + sessionStore.persist({ authenticated: { token: 'token1234' } }); + assert.equal(applicationAdapter.headers.foo, 'bar'); + assert.equal(applicationAdapter.headers.Authorization, 'Bearer token1234'); + }); + + test('it adds an org_id to the session data if authenticating within a scope', async function (assert) { + assert.expect(2); + let sessionData = {}; + sessionStore.set('scope', {}); + sessionStore.persist(sessionData); + assert.notOk(sessionData.authenticated, 'There is no authenticated key'); + sessionData = { authenticated: {} }; + sessionStore.set('scope', { org: { id: 1 } }); + sessionStore.persist(sessionData); + assert.equal(sessionData.authenticated.org_id, 1); + }); +}); diff --git a/ui/core/translations/en-us.yaml b/ui/core/translations/en-us.yaml index e770de25c..e4af341c7 100644 --- a/ui/core/translations/en-us.yaml +++ b/ui/core/translations/en-us.yaml @@ -4,6 +4,21 @@ actions: dismiss: Dismiss save: Save help: Need help? + authenticate: Authenticate + deauthenticate: Deauthenticate + choose-org: Choose Org + choose-different-org: Choose a Different Org +auth: + titles: + no-orgs: No Organizations Available + no-methods: No Authentication Method + descriptions: + no-orgs: No organizations were found. To authenticate, ask your administrator to create an organization and grant you access. + no-methods: No authentication methods were found for this organization. Switch organizations and try again. + choose-org: Choose an organization to authenticate. + authenticating-with-org: "You're authenticating with {name}." + method-types: + password: Password resources: user: User users: Users @@ -46,6 +61,8 @@ form: name: Name description: Description type: Type + identification: Username + password: Password errors: titles: unauthenticated-error: You are not signed in diff --git a/yarn.lock b/yarn.lock index c7cf88da0..5304e5ace 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5335,6 +5335,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base-64@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" + integrity sha1-eAqZyE59YAJgNhURxId2E78k9rs= + base64-arraybuffer@0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" @@ -5707,6 +5712,23 @@ broccoli-concat@^3.2.2, broccoli-concat@^3.7.1, broccoli-concat@^3.7.4, broccoli lodash.uniq "^4.2.0" walk-sync "^0.3.2" +broccoli-concat@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/broccoli-concat/-/broccoli-concat-4.2.4.tgz#78e359ddc540b999d815355163bf3cfb6bd67322" + integrity sha512-NgdBIE57r+U/AslBohQr0mCS7PopIWL8dihMI1CzqffQkisAgqWMuddjYmizqRBQlml7crBFaBeUnPDHhf4/RQ== + dependencies: + broccoli-debug "^0.6.5" + broccoli-kitchen-sink-helpers "^0.3.1" + broccoli-plugin "^4.0.2" + ensure-posix-path "^1.0.2" + fast-sourcemap-concat "^2.1.0" + find-index "^1.1.0" + fs-extra "^8.1.0" + fs-tree-diff "^2.0.1" + lodash.merge "^4.6.2" + lodash.omit "^4.1.0" + lodash.uniq "^4.2.0" + broccoli-config-loader@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/broccoli-config-loader/-/broccoli-config-loader-1.0.1.tgz#d10aaf8ebc0cb45c1da5baa82720e1d88d28c80a" @@ -5744,7 +5766,7 @@ broccoli-file-creator@^1.1.1: broccoli-plugin "^1.1.0" mkdirp "^0.5.1" -broccoli-file-creator@^2.1.1: +broccoli-file-creator@^2.0.0, broccoli-file-creator@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/broccoli-file-creator/-/broccoli-file-creator-2.1.1.tgz#7351dd2496c762cfce7736ce9b49e3fce0c7b7db" integrity sha512-YpjOExWr92C5vhnK0kmD81kM7U09kdIRZk9w4ZDCDHuHXW+VE/x6AGEOQQW3loBQQ6Jk+k+TSm8dESy4uZsnjw== @@ -5816,7 +5838,7 @@ broccoli-funnel@^1.0.1: symlink-or-copy "^1.0.0" walk-sync "^0.3.1" -broccoli-funnel@^2.0.0, broccoli-funnel@^2.0.1, broccoli-funnel@^2.0.2, broccoli-funnel@~2.0.2: +"broccoli-funnel@^1.2.0 || ^2.0.0", broccoli-funnel@^2.0.0, broccoli-funnel@^2.0.1, broccoli-funnel@^2.0.2, broccoli-funnel@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/broccoli-funnel/-/broccoli-funnel-2.0.2.tgz#0edf629569bc10bd02cc525f74b9a38e71366a75" integrity sha512-/vDTqtv7ipjEZQOVqO4vGDVAOZyuYzQ/EgGoyewfOgh1M7IQAToBKZI0oAQPgMBeFPPlIbfMuAngk+ohPBuaHQ== @@ -5896,7 +5918,7 @@ broccoli-merge-trees@^2.0.0: broccoli-plugin "^1.3.0" merge-trees "^1.0.1" -broccoli-merge-trees@^3.0.0, broccoli-merge-trees@^3.0.1, broccoli-merge-trees@^3.0.2, broccoli-merge-trees@~3.0.2: +"broccoli-merge-trees@^2.0.0 || ^3.0.0", broccoli-merge-trees@^3.0.0, broccoli-merge-trees@^3.0.1, broccoli-merge-trees@^3.0.2, broccoli-merge-trees@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/broccoli-merge-trees/-/broccoli-merge-trees-3.0.2.tgz#f33b451994225522b5c9bcf27d59decfd8ba537d" integrity sha512-ZyPAwrOdlCddduFbsMyyFzJUrvW6b04pMvDiAQZrCwghlvgowJDY+EfoXn+eR1RRA5nmGHJ+B68T63VnpRiT1A== @@ -8329,7 +8351,7 @@ ember-cli-babel-plugin-helpers@^1.0.0, ember-cli-babel-plugin-helpers@^1.1.0: resolved "https://registry.yarnpkg.com/ember-cli-babel-plugin-helpers/-/ember-cli-babel-plugin-helpers-1.1.0.tgz#de3baedd093163b6c2461f95964888c1676325ac" integrity sha512-Zr4my8Xn+CzO0gIuFNXji0eTRml5AxZUTDQz/wsNJ5AJAtyFWCY4QtKdoELNNbiCVGt1lq5yLiwTm4scGKu6xA== -ember-cli-babel@7: +ember-cli-babel@7, ember-cli-babel@^7.1.0: version "7.21.0" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.21.0.tgz#c79e888876aee87dfc3260aee7cb580b74264bbc" integrity sha512-jHVi9melAibo0DrAG3GAxid+29xEyjBoU53652B4qcu3Xp58feZGTH/JGXovH7TjvbeNn65zgNyoV3bk1onULw== @@ -8901,6 +8923,14 @@ ember-compatibility-helpers@^1.1.1, ember-compatibility-helpers@^1.1.2, ember-co ember-cli-version-checker "^2.1.1" semver "^5.4.1" +ember-cookies@^0.5.0, ember-cookies@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/ember-cookies/-/ember-cookies-0.5.2.tgz#06e33463f2f83254fefaf224cc944dec3fb9d3ba" + integrity sha512-nZ7oG97kBcO9UHjO95ryABpnVx62Bhxo7lIsBJNmWvFXleILm9DGueiAynzXxuYWWPuKIeeSbYakrS1869tNTw== + dependencies: + ember-cli-babel "^7.1.0" + ember-getowner-polyfill "^1.1.0 || ^2.0.0" + ember-copy@1.0.0, ember-copy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/ember-copy/-/ember-copy-1.0.0.tgz#426554ba6cf65920f31d24d0a3ca2cb1be16e4aa" @@ -9031,6 +9061,25 @@ ember-fetch@^7.0.0: node-fetch "^2.6.0" whatwg-fetch "^3.0.0" +ember-fetch@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/ember-fetch/-/ember-fetch-8.0.1.tgz#dc57f3c3ba464e4a8722785f6b44265c46b38020" + integrity sha512-J+knuBFQJlMQUuij2tziMiVcJ+Ss0lCV2XiCQO2cHmG2PUsfvLlTGZ04uUyOXPqrJISxY2W3Et43fQBg4jy7SA== + dependencies: + abortcontroller-polyfill "^1.4.0" + broccoli-concat "^4.2.4" + broccoli-debug "^0.6.5" + broccoli-merge-trees "^4.2.0" + broccoli-rollup "^2.1.1" + broccoli-stew "^3.0.0" + broccoli-templater "^2.0.1" + calculate-cache-key-for-tree "^2.0.0" + caniuse-api "^3.0.0" + ember-cli-babel "^7.13.2" + ember-cli-typescript "^3.1.3" + node-fetch "^2.6.0" + whatwg-fetch "^3.0.0" + ember-get-config@0.2.4, ember-get-config@^0.2.2, ember-get-config@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/ember-get-config/-/ember-get-config-0.2.4.tgz#118492a2a03d73e46004ed777928942021fe1ecd" @@ -9039,7 +9088,7 @@ ember-get-config@0.2.4, ember-get-config@^0.2.2, ember-get-config@^0.2.4: broccoli-file-creator "^1.1.1" ember-cli-babel "^6.3.0" -ember-getowner-polyfill@^2.0.0: +"ember-getowner-polyfill@^1.1.0 || ^2.0.0", ember-getowner-polyfill@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ember-getowner-polyfill/-/ember-getowner-polyfill-2.2.0.tgz#38e7dccbcac69d5ec694000329ec0b2be651d2b2" integrity sha512-rwGMJgbGzxIAiWYjdpAh04Abvt0s3HuS/VjHzUFhVyVg2pzAuz45B9AzOxYXzkp88vFC7FPaiA4kE8NxNk4A4Q== @@ -9247,6 +9296,20 @@ ember-router-generator@^2.0.0: "@babel/traverse" "^7.4.5" recast "^0.18.1" +ember-simple-auth@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ember-simple-auth/-/ember-simple-auth-3.0.0.tgz#d994f512cda1a8a5a6ef5a853fac60c0c0d6acd6" + integrity sha512-JaEw5W/Z9+HIH9I2BmE/JD4KofH4rp79Y51zJpt3UQI1OwWFOzFa+ulN0rK/DVLpGOz7uL7hBIE/rtEcEN1eqA== + dependencies: + base-64 "^0.1.0" + broccoli-file-creator "^2.0.0" + broccoli-funnel "^1.2.0 || ^2.0.0" + broccoli-merge-trees "^2.0.0 || ^3.0.0" + ember-cli-babel "^7.12.0" + ember-cli-is-package-missing "^1.0.0" + ember-cookies "^0.5.0" + silent-error "^1.0.0" + ember-source-channel-url@^1.0.1: version "1.2.0" resolved "https://registry.yarnpkg.com/ember-source-channel-url/-/ember-source-channel-url-1.2.0.tgz#77eb9d0889e5f5370e6c70fcb2696c63ff4a34a1" @@ -10209,6 +10272,20 @@ fast-sourcemap-concat@^1.4.0: source-map-url "^0.3.0" sourcemap-validator "^1.1.0" +fast-sourcemap-concat@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-sourcemap-concat/-/fast-sourcemap-concat-2.1.0.tgz#12dd36bfc38c804093e4bd1de61dd6216f574211" + integrity sha512-L9uADEnnHOeF4U5Kc3gzEs3oFpNCFkiTJXvT+nKmR0zcFqHZJJbszWT7dv4t9558FJRGpCj8UxUpTgz2zwiIZA== + dependencies: + chalk "^2.0.0" + fs-extra "^5.0.0" + heimdalljs-logger "^0.1.9" + memory-streams "^0.1.3" + mkdirp "^0.5.0" + source-map "^0.4.2" + source-map-url "^0.3.0" + sourcemap-validator "^1.1.0" + fastq@^1.6.0: version "1.8.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481"