0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Switched editor re-authenticate modal to new modal pattern

refs https://github.com/TryGhost/Team/issues/1734
refs https://github.com/TryGhost/Team/issues/559
refs https://github.com/TryGhost/Ghost/issues/14101

- switches to newer modal patterns ready for later Ember upgrades
- simplified unauthed save behaviour because we now have a promise for the modal enabling us to wait for the modal to close before continuing
This commit is contained in:
Kevin Ansfield 2022-11-11 16:21:03 +00:00
parent f74913cfb6
commit 332dd4fbf1
9 changed files with 153 additions and 168 deletions

View file

@ -0,0 +1,37 @@
<div class="modal-content" data-test-modal="re-authenticate">
<header class="modal-header">
<h1>Please re-authenticate</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body {{if this.authenticationError 'error'}}">
<form id="login" class="login-form" method="post" novalidate="novalidate" {{on "submit" (perform this.reauthenticateTask)}}>
<GhValidationStatusContainer @class="password-wrap" @errors={{this.signup.errors}} @property="password" @hasValidated={{this.signup.hasValidated}}>
<input
type="password"
class="gh-input password"
placeholder="Password"
name="password"
value={{this.signup.password}}
aria-label="Your password"
{{on "input" this.setPassword}}
/>
</GhValidationStatusContainer>
<div>
<GhTaskButton
@type="submit"
@buttonText="Log in"
@runningText="Authenticating"
@showSuccess={{false}}
@task={{this.reauthenticateTask}}
@class="gh-btn gh-btn-black gh-btn-icon"
/>
</div>
</form>
{{#if this.authenticationError}}
<p class="response">{{this.authenticationError}}</p>
{{/if}}
</div>
</div>

View file

@ -0,0 +1,93 @@
import Component from '@glimmer/component';
import EmberObject from '@ember/object';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import classic from 'ember-classic-decorator';
import {action} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
// EmberObject is still needed here for ValidationEngine
@classic
class Signin extends EmberObject.extend(ValidationEngine) {
@tracked identification = '';
@tracked password = '';
validationType = 'signin';
}
export default class ReAuthenticateModal extends Component {
@service notifications;
@service session;
@tracked authenticationError = null;
constructor() {
super(...arguments);
this.signin = Signin.create();
this.signin.identification = this.session.user.email;
}
@action
setPassword(event) {
this.signin.password = event.target.value;
}
@task({drop: true})
*reauthenticateTask() {
// Manually trigger events for input fields, ensuring legacy compatibility with
// browsers and password managers that don't send proper events on autofill
const inputs = document.querySelectorAll('#login input');
inputs.forEach(input => input.dispatchEvent(new Event('change')));
this.authenticationError = null;
try {
yield this.signin.validate({property: 'signin'});
} catch (error) {
this.signin.hasValidated.pushObject('password');
return false;
}
try {
yield this._authenticate();
this.notifications.closeAlerts();
this.args.close();
return true;
} catch (error) {
if (!error) {
return;
}
if (error?.payload?.errors) {
error.payload.errors.forEach((err) => {
if (isVersionMismatchError(err)) {
return this.notifications.showAPIError(error);
}
err.message = htmlSafe(err.context || err.message);
});
this.signin.errors.add('password', 'Incorrect password');
this.signin.hasValidated.pushObject('password');
this.authenticationError = error.payload.errors[0].message;
}
this.notifications.showAPIError(error);
}
}
async _authenticate() {
const authStrategy = 'authenticator:cookie';
const {identification, password} = this.signin;
this.session.skipAuthSuccessHandler = true;
try {
await this.session.authenticate(authStrategy, identification, password);
} finally {
this.session.skipAuthSuccessHandler = undefined;
}
}
}

View file

@ -1,26 +0,0 @@
<header class="modal-header">
<h1>Please re-authenticate</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body {{if this.authenticationError 'error'}}">
<form id="login" class="login-form" method="post" novalidate="novalidate" {{action "confirm" on="submit"}}>
<GhValidationStatusContainer @class="password-wrap" @errors={{this.errors}} @property="password" @hasValidated={{this.hasValidated}}>
<GhTextInput
@class="password"
@type="password"
@placeholder="Password"
@name="password"
@value={{readonly this.password}}
@input={{action (mut this.password) value="target.value"}} />
</GhValidationStatusContainer>
<div>
<GhTaskButton @buttonText="Log in" @task={{this.reauthenticate}} @class="gh-btn gh-btn-black gh-btn-icon" @type="submit" />
</div>
</form>
{{#if this.authenticationError}}
<p class="response">{{this.authenticationError}}</p>
{{/if}}
</div>

View file

@ -1,78 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {htmlSafe} from '@ember/template';
import {inject} from 'ghost-admin/decorators/inject';
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
import {reads} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend(ValidationEngine, {
notifications: service(),
session: service(),
validationType: 'signin',
authenticationError: null,
config: inject(),
identification: reads('session.user.email'),
actions: {
confirm() {
this.reauthenticate.perform();
}
},
_authenticate() {
let session = this.session;
let authStrategy = 'authenticator:cookie';
let identification = this.identification;
let password = this.password;
session.set('skipAuthSuccessHandler', true);
this.toggleProperty('submitting');
return session.authenticate(authStrategy, identification, password).finally(() => {
this.toggleProperty('submitting');
session.set('skipAuthSuccessHandler', undefined);
});
},
_passwordConfirm() {
// Manually trigger events for input fields, ensuring legacy compatibility with
// browsers and password managers that don't send proper events on autofill
const inputs = document.querySelectorAll('#login input');
inputs.forEach(input => input.dispatchEvent(new Event('change')));
this.set('authenticationError', null);
return this.validate({property: 'signin'}).then(() => this._authenticate().then(() => {
this.notifications.closeAlerts();
this.send('closeModal');
return true;
}).catch((error) => {
if (error && error.payload && error.payload.errors) {
error.payload.errors.forEach((err) => {
if (isVersionMismatchError(err)) {
return this.notifications.showAPIError(error);
}
err.message = htmlSafe(err.context || err.message);
});
this.errors.add('password', 'Incorrect password');
this.hasValidated.pushObject('password');
this.set('authenticationError', error.payload.errors[0].message);
}
}), () => {
this.hasValidated.pushObject('password');
return false;
});
},
reauthenticate: task(function* () {
return yield this._passwordConfirm();
}).drop()
});

View file

@ -4,6 +4,7 @@ import DeletePostModal from '../components/modals/delete-post';
import DeleteSnippetModal from '../components/editor/modals/delete-snippet';
import PostModel from 'ghost-admin/models/post';
import PublishLimitModal from '../components/modals/limits/publish-limit';
import ReAuthenticateModal from '../components/editor/modals/re-authenticate';
import UpdateSnippetModal from '../components/editor/modals/update-snippet';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import classic from 'ember-classic-decorator';
@ -112,7 +113,6 @@ export default class EditorController extends Controller {
/* public properties -----------------------------------------------------*/
shouldFocusTitle = false;
showReAuthenticateModal = false;
showSettingsMenu = false;
/**
@ -127,7 +127,6 @@ export default class EditorController extends Controller {
_leaveConfirmed = false;
_previousTagNames = null; // set by setPost and _postSaved, used in hasDirtyAttributes
_reAuthenticateModalToggle = false;
/* computed properties ---------------------------------------------------*/
@ -266,22 +265,6 @@ export default class EditorController extends Controller {
}
}
@action
toggleReAuthenticateModal() {
this._reAuthenticateModalToggle = true;
if (this.showReAuthenticateModal) {
// closing, re-attempt save if needed
if (this._reauthSave) {
this.saveTask.perform(this._reauthSaveOptions);
}
this._reauthSave = false;
this._reauthSaveOptions = null;
}
this.toggleProperty('showReAuthenticateModal');
}
@action
openUpgradeModal(hostLimitError = {}) {
this.modals.open(PublishLimitModal, {
@ -467,15 +450,13 @@ export default class EditorController extends Controller {
return post;
} catch (error) {
if (!this.session.isAuthenticated && !this._reAuthenticateModalToggle) {
this.toggleProperty('showReAuthenticateModal');
}
if (!this.session.isAuthenticated) {
yield this.modals.open(ReAuthenticateModal);
this._reAuthenticateModalToggle = false;
if (this.showReAuthenticateModal) {
this._reauthSave = true;
this._reauthSaveOptions = options;
return;
if (this.session.isAuthenticated) {
this.saveTask.perform(options);
return;
}
}
this.set('post.status', prevStatus);

View file

@ -4,6 +4,7 @@ import DeletePostModal from '../components/modals/delete-post';
import DeleteSnippetModal from '../components/editor/modals/delete-snippet';
import PostModel from 'ghost-admin/models/post';
import PublishLimitModal from '../components/modals/limits/publish-limit';
import ReAuthenticateModal from '../components/editor/modals/re-authenticate';
import UpdateSnippetModal from '../components/editor/modals/update-snippet';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import classic from 'ember-classic-decorator';
@ -112,7 +113,6 @@ export default class LexicalEditorController extends Controller {
/* public properties -----------------------------------------------------*/
shouldFocusTitle = false;
showReAuthenticateModal = false;
showSettingsMenu = false;
/**
@ -127,7 +127,6 @@ export default class LexicalEditorController extends Controller {
_leaveConfirmed = false;
_previousTagNames = null; // set by setPost and _postSaved, used in hasDirtyAttributes
_reAuthenticateModalToggle = false;
/* computed properties ---------------------------------------------------*/
@ -266,22 +265,6 @@ export default class LexicalEditorController extends Controller {
}
}
@action
toggleReAuthenticateModal() {
this._reAuthenticateModalToggle = true;
if (this.showReAuthenticateModal) {
// closing, re-attempt save if needed
if (this._reauthSave) {
this.saveTask.perform(this._reauthSaveOptions);
}
this._reauthSave = false;
this._reauthSaveOptions = null;
}
this.toggleProperty('showReAuthenticateModal');
}
@action
openUpgradeModal(hostLimitError = {}) {
this.modals.open(PublishLimitModal, {
@ -468,15 +451,13 @@ export default class LexicalEditorController extends Controller {
return post;
} catch (error) {
if (!this.session.isAuthenticated && !this._reAuthenticateModalToggle) {
this.toggleProperty('showReAuthenticateModal');
}
if (!this.session.isAuthenticated) {
yield this.modals.open(ReAuthenticateModal);
this._reAuthenticateModalToggle = false;
if (this.showReAuthenticateModal) {
this._reauthSave = true;
this._reauthSaveOptions = options;
return;
if (this.session.isAuthenticated) {
this.saveTask.perform(options);
return;
}
}
this.set('post.status', prevStatus);

View file

@ -38,7 +38,8 @@ export default AuthenticatedRoute.extend({
},
authorizationFailed() {
this.controller.send('toggleReAuthenticateModal');
// noop - re-auth is handled by controller save
return;
},
willTransition(transition) {

View file

@ -120,12 +120,6 @@
<span>{{svg-jar "sidemenu"}}</span>
{{/if}}
</button>
{{#if this.showReAuthenticateModal}}
<GhFullscreenModal @modal="re-authenticate"
@close={{this.toggleReAuthenticateModal}}
@modifier="action wide" />
{{/if}}
{{/if}}
{{outlet}}

View file

@ -3,7 +3,7 @@ import windowProxy from 'ghost-admin/utils/window-proxy';
import {Response} from 'miragejs';
import {afterEach, beforeEach, describe, it} from 'mocha';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {currentRouteName, currentURL, fillIn, findAll, triggerKeyEvent, visit} from '@ember/test-helpers';
import {click, currentRouteName, currentURL, fillIn, findAll, triggerKeyEvent, visit, waitFor} from '@ember/test-helpers';
import {expect} from 'chai';
import {run} from '@ember/runloop';
import {setupApplicationTest} from 'ember-mocha';
@ -115,7 +115,6 @@ describe('Acceptance: Authentication', function () {
});
});
// TODO: re-enable once modal reappears correctly
describe('editor', function () {
let origDebounce = run.debounce;
let origThrottle = run.throttle;
@ -160,20 +159,23 @@ describe('Acceptance: Authentication', function () {
});
// we shouldn't have a modal at this point
expect(findAll('.modal-container #login').length, 'modal exists').to.equal(0);
expect(findAll('[data-test-modal="re-authenticate"]').length, 'modal exists').to.equal(0);
// we also shouldn't have any alerts
expect(findAll('.gh-alert').length, 'no of alerts').to.equal(0);
// update the post
testOn = 'edit';
await fillIn('.__mobiledoc-editor', 'Edited post body');
await triggerKeyEvent('.gh-editor-title', 'keydown', 83, {
triggerKeyEvent('.gh-editor-title', 'keydown', 83, {
metaKey: ctrlOrCmd === 'command',
ctrlKey: ctrlOrCmd === 'ctrl'
});
// we should see a re-auth modal
expect(findAll('.fullscreen-modal #login').length, 'modal exists').to.equal(1);
await waitFor('[data-test-modal="re-authenticate"]', {timeout: 100});
// close the modal so the modal promise is settled and we can continue
await click('[data-test-modal="re-authenticate"] button[title="Close"]');
});
// don't clobber debounce/throttle for future tests