feat: 🎸 add initial auth flow

This commit is contained in:
Randall Morey committed 2020-11-12 15:13:28 -05:00
1 parent f9693ec365
commit 5ecf0eaad8
24 files changed
+588 -30

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
/**
* This helper returns true or false based on the state of the ember-loading
* service's `isLoading` attribute.
*/
export default class extends Helper {
// =services
@service loading;
// =methods
/**
* Returns true if "something" is loading according to ember-loading.
* @return {boolean}
*/
compute() {
return this.loading.isLoading;
}
}
+3
View File
@@ -10,6 +10,9 @@ Router.map(function () {
this.route('scopes', function() {
this.route('scope', { path: ':scope_id' }, function() {
this.route('targets');
this.route('authenticate', function() {
this.route('method', { path: ':auth_method_id' });
});
});
});
});
+29
View File
@@ -3,6 +3,7 @@ import Route from '@ember/routing/route';
import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import loading from 'ember-loading/decorator';
export default class ApplicationRoute extends Route.extend(
ApplicationRouteMixin
@@ -54,4 +55,32 @@ export default class ApplicationRoute extends Route.extend(
this.session.invalidate();
}
/**
* Hooks into ember-loading to kick off loading indicator in the
* application template.
* @return {boolean} always returns true
*/
@action
@loading
loading() {
return true;
}
/**
* Invalidates the session if a 401 error occurs and returns false to
* prevent further error handling.
* Returns true in all other cases, allowing error handling to occur (such
* as displaying the `error.hbs` template, if one exists).
* @param {Error} e
*/
@action
error(e) {
const isUnauthenticated = A(e?.errors)?.firstObject?.isUnauthenticated;
if (isUnauthenticated) {
this.session.invalidate();
return false;
}
return true;
}
}
+1 -16
View File
@@ -1,27 +1,12 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class IndexRoute extends Route {
// =services
@service ipc;
// =methods
/**
* Invokes an IPC method and returns the result.
* @return {Promise}
*/
model() {
return this.ipc.invoke('getFoobars', {
id: '1'
});
}
/**
* Redirects to scopes route for further processing.
*/
redirect() {
this.transitionTo('scopes');
this.replaceWith('scopes');
}
}
+29
View File
@@ -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() {
const authenticatedScopeID = get(
this.session,
'data.authenticated.scope.id'
);
if (authenticatedScopeID) {
this.replaceWith('scopes.scope', authenticatedScopeID);
} else {
this.replaceWith('scopes.scope', 'global');
}
}
}
+17 -7
View File
@@ -5,6 +5,7 @@ import { A } from '@ember/array';
export default class ScopesScopeRoute extends Route {
// =services
@service session;
@service scope;
// =methods
@@ -45,24 +46,33 @@ export default class ScopesScopeRoute extends Route {
let orgs;
orgs = await this.store.query('scope', { scope_id: 'global' })
.catch(() => A([]));
// Then pull out the "selected" scopes, if relevant
// Then pull out the "selected" org, if relevant
let selectedOrg;
if (model.isGlobal || model.isOrg) selectedOrg = model;
let projects;
if(model.isGlobal) {
projects = orgs.map((id) => this.store.query('scope', { scope_id: id }));
}
if(model.isOrg) {
projects = this.store.query('scope', { scope_id: model.id });
if (model.isOrg) {
projects = await this.store.query('scope', { scope_id: model.id });
}
// Update the scope service with the current scope(s);
this.scope.org = selectedOrg;
this.scope.projects = await projects;
this.scope.projects = projects;
this.scopes = { orgs, selectedOrg };
}
/**
*
*/
redirect(model) {
const { isAuthenticated } = this.session;
const { isGlobal } = model;
if (isAuthenticated && isGlobal && this.scopes.orgs?.length) {
this.replaceWith('scopes.scope', this.scopes.orgs.firstObject.id);
}
}
/**
* Adds the scopes hash to the controller context (see `afterModel`).
* @param {Controller} controller
@@ -0,0 +1,29 @@
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;
// =methods
beforeModel() {
if (this.session.isAuthenticated) this.replaceWith('scopes.scope.index');
}
/**
* 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 { id: scope_id } = this.modelFor('scopes.scope');
return hash({
scope: this.modelFor('scopes.scope'),
scopes: this.modelFor('scopes').filter(scope => scope.isOrg),
authMethods: this.store.query('auth-method', { scope_id })
});
}
}
@@ -0,0 +1,18 @@
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.replaceWith('scopes.scope.authenticate.method', firstAuthMethod);
}
}
}
@@ -0,0 +1,62 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import loading from 'ember-loading/decorator';
export default class ScopesScopeAuthenticateMethodRoute extends Route {
// =services
@service session;
@service notify;
@service intl;
// =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
@loading
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.replaceWith('index');
} catch (e) {
const errorMessage = this.intl.t('errors.authentication-failed.title');
this.notify.error(errorMessage, { closeAfter: null });
}
}
}
+14 -3
View File
@@ -1,12 +1,23 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class ScopesScopeIndexRoute extends Route {
// =services
@service session;
// =methods
/**
* Redirects to scopes route for further processing.
* Redirects to scopes/scope/authenticate for further processing.
* If already authenticated, redirects to scopes/scope/scopes to display
* list of sub scopes.
*/
redirect() {
this.transitionTo('scopes.scope.targets');
redirect(model) {
if (!this.session.isAuthenticated) {
this.replaceWith('scopes.scope.authenticate');
} else {
this.replaceWith('scopes.scope.targets');
}
}
}
+10 -2
View File
@@ -1,20 +1,28 @@
import Route from '@ember/routing/route';
import { all } from 'rsvp';
import { inject as service } from '@ember/service';
import { all } from 'rsvp';
export default class ScopesScopeTargetsRoute extends Route {
// =services
@service session;
@service scope;
// =methods
/**
* If arriving here unauthenticated, redirect to index for further processing.
*/
beforeModel() {
if (!this.session.isAuthenticated) this.transitionTo('index');
}
/**
* Loads all targets under current scope.
* @return {Promise{[TargetModel]}}
*/
async model() {
let projectTargets = await all(this.scope.projects.map(({ id: scope_id }) =>
let projectTargets = await all(this.scope.projects.map(({ id: scope_id }) =>
this.store.query('target', { scope_id })
))
+1 -1
View File
@@ -14,7 +14,7 @@ export default class ScopeService extends Service {
*/
@tracked org;
/**
/**
* @type {[ScopeModel]}
*/
@tracked projects;
+96
View File
@@ -0,0 +1,96 @@
// This file is essentially a rip of the default ember notify styles with some minor tweaks for positioning.
// sass-lint:disable no-vendor-prefixes
@-webkit-keyframes 'ember-notify-show' {
from {
left: -310px;
}
to {
left: 0;
}
}
@keyframes 'ember-notify-show' {
from {
left: -310px;
}
to {
left: 0;
}
}
@-webkit-keyframes 'ember-notify-hide' {
from {
left: 0;
}
to {
left: -310px;
}
}
@keyframes 'ember-notify-hide' {
from {
left: 0;
}
to {
left: -310px;
}
}
.ember-notify-cn {
bottom: 0;
left: 0;
position: fixed;
z-index: 5000;
}
.ember-notify {
box-sizing: border-box;
display: block;
margin: 10px;
position: relative;
width: 300px;
.close {
cursor: pointer;
float: right;
}
}
.ember-notify-show {
-webkit-animation-duration: 500ms;
-webkit-animation-name: ember-notify-show;
-webkit-animation-timing-function: cubic-bezier(.175, .885, .32, 1.275);
animation-duration: 500ms;
animation-name: ember-notify-show;
animation-timing-function: cubic-bezier(.175, .885, .32, 1.275);
}
.ember-notify-hide {
-webkit-animation-duration: 250ms;
-webkit-animation-name: ember-notify-hide;
-webkit-animation-timing-function: cubic-bezier(.6, -.28, .735, .045);
animation-duration: 250ms;
animation-name: ember-notify-hide;
animation-timing-function: cubic-bezier(.6, -.28, .735, .045);
&:hover {
-moz-animation-play-state: paused;
-webkit-animation-play-state: paused;
animation-play-state: paused;
}
}
@media only screen and (max-width: 680px) {
.ember-notify-cn {
box-sizing: border-box;
padding-left: 20px;
width: 100%;
}
.ember-notify {
float: right;
width: 100%;
}
}
+174
View File
@@ -1 +1,175 @@
@use 'rose/variables/sizing';
@use 'rose/variables/media';
@import 'rose';
@import 'notify';
.rose-layout-centered {
> .rose-layout-page {
width: sizing.rems(l) * 18;
}
@media (max-width: media.width(small)) {
display: block;
> .rose-layout-page {
width: auto;
}
}
}
.rose-nav-sidebar {
margin-top: sizing.rems(xxl);
& + .rose-nav-sidebar {
margin-top: 0;
}
.rose-icon {
margin-right: sizing.rems(xs);
}
}
.rose-form {
&:not(.full-width) {
width: 66%;
}
.rose-form-input {
width: 100%;
.rose-form-input-field {
width: 100%;
}
}
.rose-dropdown {
$iconWidth: sizing.rems(m) + sizing.rems(s);
$indent: $iconWidth + sizing.rems(xs);
.rose-form-radio-label {
display: flex;
.rose-icon {
padding-right: sizing.rems(xxs);
}
}
.indent-label-1 {
.rose-form-radio-label {
padding-left: $indent;
}
}
.indent-label-2 {
.rose-form-radio-label {
padding-left: $indent + $iconWidth;
}
}
.is-project {
.rose-icon {
visibility: hidden;
}
}
}
fieldset {
margin-bottom: sizing.rems(m);
legend {
@extend .rose-form-label;
}
}
}
.rose-table {
p {
margin-bottom: 0;
}
.rose-form-checkbox {
margin-bottom: 0;
.rose-form-checkbox-label-text {
color: var(--stark);
text-decoration: underline;
}
}
}
.rose-header {
position: relative;
.logotype {
display: none;
}
.rose-dropdown {
.rose-icon {
transform: scale(0.7);
}
}
&.loading {
&::after {
animation: gradient 1s linear infinite;
background: repeating-linear-gradient(to right, var(--subtle), var(--subtle), var(--info), var(--subtle), var(--subtle));
background-position: -100% 0;
background-size: 200% auto;
content: ' ';
height: 3px;
left: 0;
position: absolute;
right: 0;
top: 100%;
@keyframes gradient {
0% { background-position: -100% 0; }
100% { background-position: -200% 0; }
}
}
}
}
.copyable {
white-space: nowrap;
.copyable-button {
margin: 0;
padding: 0 sizing.rems(xxs);
position: relative;
top: -2px;
}
mark {
animation: fadeBackground 0.75s linear;
background-color: transparent;
color: inherit;
@keyframes fadeBackground {
0% { background-color: var(--action-subtler-1); }
100% { background-color: transparent; }
}
}
}
.doc-link {
.large {
svg {
transform: scale(0.8);
}
}
}
[class*='session-status-'] {
color: var(--ui-gray-subtler-2);
}
.session-status-active {
color: var(--success);
}
.nowrap {
white-space: nowrap;
}
+1 -1
View File
@@ -3,7 +3,7 @@
<Rose::Layout::Global as |layout|>
<layout.header>
<Rose::Header as |header|>
<Rose::Header class="{{if (is-loading) " loading"}}" as |header|>
<header.brand @logo="logo-app" @text={{app-name}} />
<header.nav>
@@ -0,0 +1 @@
{{outlet}}
@@ -0,0 +1 @@
{{outlet}}
@@ -0,0 +1,27 @@
<Rose::Form
class="full-width"
@onSubmit={{route-action "authenticate" (hash
identification=this.identification
password=this.password
)}}
@cancel={{false}}
as |form|
>
<form.input
@name="identification"
@type="text"
@value={{this.identification}}
@label={{t "form.login_name.label"}} />
<form.input
@name="password"
@type="password"
@value={{this.password}}
@label={{t "form.password.label"}} />
<form.actions
@submitText={{t "actions.authenticate"}}
@showCancel={{false}} />
</Rose::Form>
+3
View File
@@ -15,6 +15,9 @@ module.exports = function(defaults) {
onlyIncluded: true,
extension: 'scss',
},
emberNotify: {
importCss: false,
},
});
// Use `app.import` to add additional libraries to the generated
+4
View File
@@ -61,14 +61,18 @@
"ember-inline-svg": "^1.0.0",
"ember-intl": "^5.5.0",
"ember-load-initializers": "^2.1.1",
"ember-loading": "^0.4.0",
"ember-maybe-import-regenerator": "^0.1.6",
"ember-modifier": "^2.1.1",
"ember-notify": "^6.0.0",
"ember-page-title": "^6.0.3",
"ember-qunit": "^4.6.0",
"ember-resolver": "^8.0.0",
"ember-route-action-helper": "^2.0.8",
"ember-simple-auth": "^3.0.1",
"ember-source": "~3.20.2",
"ember-template-lint": "^2.9.1",
"ember-uuid": "^2.1.0",
"ember-welcome-page": "^4.0.0",
"eslint": "^7.5.0",
"eslint-plugin-ember": "^8.9.1",
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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);
});
});