🎨 Added auto-login to private site when viewing site preview in admin (#1286)

closes https://github.com/TryGhost/Ghost/issues/10995

- when first loading the site preview, if private mode is enabled submit the login form in the background to get the cookie before loading the iframe
- refactors post-authentication preloading to ensure it occurs before post-authentication route hooks are called
- adds `showSuccess` attribute to `<GhTaskButton>` so that when set to `false` it can stay in the running state after "success" to avoid state change flashes whilst waiting for a transition
This commit is contained in:
Kevin Ansfield 2019-08-12 09:11:10 +01:00 committed by GitHub
parent 083a8c054f
commit 457a8e2955
11 changed files with 112 additions and 69 deletions

View File

@ -5,7 +5,11 @@ import {inject as service} from '@ember/service';
export default Authenticator.extend({
ajax: service(),
config: service(),
feature: service(),
ghostPaths: service(),
settings: service(),
tour: service(),
sessionEndpoint: computed('ghostPaths.apiRoot', function () {
return `${this.ghostPaths.apiRoot}/session`;
@ -24,7 +28,19 @@ export default Authenticator.extend({
dataType: 'text'
};
return this.ajax.post(this.sessionEndpoint, options);
return this.ajax.post(this.sessionEndpoint, options).then((authResult) => {
// TODO: remove duplication with application.afterModel
let preloadPromises = [
this.config.fetchAuthenticated(),
this.feature.fetch(),
this.settings.fetch(),
this.tour.fetchViewed()
];
return RSVP.all(preloadPromises).then(() => {
return authResult;
});
});
},
invalidate() {

View File

@ -32,6 +32,7 @@ const GhTaskButton = Component.extend({
buttonText: 'Save',
idleClass: '',
runningClass: '',
showSuccess: true, // set to false if you want the spinner to show until a transition occurs
successText: 'Saved',
successClass: 'gh-btn-green',
failureText: 'Retry',
@ -40,7 +41,6 @@ const GhTaskButton = Component.extend({
// Allowed actions
action: () => {},
isRunning: reads('task.last.isRunning'),
runningText: reads('buttonText'),
// hasRun is needed so that a newly rendered button does not show the last
@ -53,12 +53,22 @@ const GhTaskButton = Component.extend({
return this.isIdle ? this.idleClass : '';
}),
isRunning: computed('task.last.isRunning', 'hasRun', 'showSuccess', function () {
let isRunning = this.get('task.last.isRunning');
if (this.hasRun && this.get('task.last.value') && !this.showSuccess) {
isRunning = true;
}
return isRunning;
}),
isRunningClass: computed('isRunning', function () {
return this.isRunning ? (this.runningClass || this.idleClass) : '';
}),
isSuccess: computed('hasRun', 'isRunning', 'task.last.value', function () {
if (!this.hasRun || this.isRunning) {
if (!this.hasRun || this.isRunning || !this.showSuccess) {
return false;
}

View File

@ -1,6 +1,5 @@
import $ from 'jquery';
import Controller, {inject as controller} from '@ember/controller';
import RSVP from 'rsvp';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {alias} from '@ember/object/computed';
import {isArray as isEmberArray} from '@ember/array';
@ -34,23 +33,15 @@ export default Controller.extend(ValidationEngine, {
actions: {
authenticate() {
this.validateAndAuthenticate.perform();
return this.validateAndAuthenticate.perform();
}
},
authenticate: task(function* (authStrategy, authentication) {
try {
let authResult = yield this.session
.authenticate(authStrategy, ...authentication);
let promises = [];
promises.pushObject(this.settings.fetch());
promises.pushObject(this.config.fetchAuthenticated());
// fetch settings and private config for synchronous access
yield RSVP.all(promises);
return authResult;
return yield this.session
.authenticate(authStrategy, ...authentication)
.then(() => true); // ensure task button transitions to "success" state
} catch (error) {
if (isVersionMismatchError(error)) {
return this.notifications.showAPIError(error);
@ -72,6 +63,7 @@ export default Controller.extend(ValidationEngine, {
this.get('signin.errors').add('password', '');
}
} else {
console.error(error); // eslint-disable-line no-console
// Connection errors don't return proper status message, only req.body
this.notifications.showAlert(
'There was a problem on the server.',
@ -96,7 +88,8 @@ export default Controller.extend(ValidationEngine, {
try {
yield this.validate({property: 'signin'});
return yield this.authenticate
.perform(authStrategy, [signin.get('identification'), signin.get('password')]);
.perform(authStrategy, [signin.get('identification'), signin.get('password')])
.then(() => true);
} catch (error) {
this.set('flowErrors', 'Please fill out the form to sign in.');
}

View File

@ -60,18 +60,13 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
this.set('appLoadTransition', transition);
transition.send('loadServerNotifications');
let configPromise = this.config.fetchAuthenticated();
let featurePromise = this.feature.fetch();
let settingsPromise = this.settings.fetch();
let tourPromise = this.tour.fetchViewed();
// return the feature/settings load promises so that we block until
// they are loaded to enable synchronous access everywhere
return RSVP.all([
configPromise,
featurePromise,
settingsPromise,
tourPromise
this.config.fetchAuthenticated(),
this.feature.fetch(),
this.settings.fetch(),
this.tour.fetchViewed()
]).then((results) => {
this._appLoaded = true;
return results;

View File

@ -1,20 +1,34 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import fetch from 'fetch';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend({
config: service(),
settings: service(),
ui: service(),
_hasLoggedIn: false,
model() {
return (new Date()).valueOf();
},
activate() {
this._super(...arguments);
},
afterModel() {
if (this.settings.get('isPrivate') && !this._hasLoggedIn) {
let privateLoginUrl = `${this.config.get('blogUrl')}/private/?r=%2F`;
deactivate() {
this._super(...arguments);
return fetch(privateLoginUrl, {
method: 'POST',
mode: 'cors',
redirect: 'manual',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `password=${this.settings.get('password')}`
}).then(() => {
this._hasLoggedIn = true;
});
}
},
buildRouteInfoMetadata() {

View File

@ -117,8 +117,6 @@ export function isThemeValidationError(errorOrStatus, payload) {
let ajaxService = AjaxService.extend({
session: service(),
isTesting: undefined,
// flag to tell our ESA authenticator not to try an invalidate DELETE request
// because it's been triggered by this service's 401 handling which means the
// DELETE would fail and get stuck in an infinite loop
@ -126,16 +124,10 @@ let ajaxService = AjaxService.extend({
skipSessionDeletion: false,
get headers() {
let headers = {};
headers['X-Ghost-Version'] = config.APP.version;
headers['App-Pragma'] = 'no-cache';
if (this.session.isAuthenticated && this.isTesting) {
headers.Authorization = 'Test';
}
return headers;
return {
'X-Ghost-Version': config.APP.version,
'App-Pragma': 'no-cache'
};
},
init() {

View File

@ -1,12 +1,9 @@
import RSVP from 'rsvp';
import SessionService from 'ember-simple-auth/services/session';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service';
export default SessionService.extend({
feature: service(),
dataStore: service('store'), // SessionService.store already exists
tour: service(),
user: computed(function () {
return this.dataStore.queryRecord('user', {id: 'me'});
@ -16,14 +13,6 @@ export default SessionService.extend({
// ensure any cached this.user value is removed and re-fetched
this.notifyPropertyChange('user');
return this._super(...arguments).then((authResult) => {
// TODO: remove duplication with application.afterModel
let preloadPromises = [
this.feature.fetch(),
this.tour.fetchViewed()
];
return RSVP.all(preloadPromises).then(() => authResult);
});
return this._super(...arguments);
}
});

View File

@ -50,6 +50,7 @@
{{gh-task-button "Sign in"
task=validateAndAuthenticate
showSuccess=false
class="login gh-btn gh-btn-blue gh-btn-block gh-btn-icon"
type="submit"
tabindex="3"}}

View File

@ -1,18 +1,7 @@
import {Response} from 'ember-cli-mirage';
import {isEmpty} from '@ember/utils';
export default function mockConfig(server) {
server.get('/config/', function ({db}, request) {
if (!request.requestHeaders.Authorization) {
return new Response(403, {}, {
errors: [{
type: 'NoPermissionError',
message: 'Authorization failed',
context: 'Unable to determine the authenticated user or integration. Check that cookies are being passed through if using session authentication.'
}]
});
}
server.get('/config/', function ({db}) {
if (isEmpty(db.configs)) {
server.loadFixtures('configs');
}

View File

@ -67,9 +67,10 @@ describe('Acceptance: Error Handling', function () {
});
it('displays alert and aborts the transition when an ember-ajax error is thrown whilst navigating', async function () {
await visit('/tags');
this.server.get('/settings/', versionMismatchResponse);
await visit('/tags');
await click('[data-test-nav="settings"]');
// navigation is blocked

View File

@ -13,6 +13,34 @@ const mockAjax = Service.extend({
}
});
const mockConfig = Service.extend({
init() {
this._super(...arguments);
this.fetchAuthenticated = sinon.stub().resolves();
}
});
const mockFeature = Service.extend({
init() {
this._super(...arguments);
this.fetch = sinon.stub().resolves();
}
});
const mockSettings = Service.extend({
init() {
this._super(...arguments);
this.fetch = sinon.stub().resolves();
}
});
const mockTour = Service.extend({
init() {
this._super(...arguments);
this.fetchViewed = sinon.stub().resolves();
}
});
const mockGhostPaths = Service.extend({
apiRoot: '/ghost/api/v2/admin'
});
@ -22,6 +50,10 @@ describe('Unit: Authenticator: cookie', () => {
beforeEach(function () {
this.owner.register('service:ajax', mockAjax);
this.owner.register('service:config', mockConfig);
this.owner.register('service:feature', mockFeature);
this.owner.register('service:settings', mockSettings);
this.owner.register('service:tour', mockTour);
this.owner.register('service:ghost-paths', mockGhostPaths);
});
@ -36,6 +68,11 @@ describe('Unit: Authenticator: cookie', () => {
let authenticator = this.owner.lookup('authenticator:cookie');
let post = authenticator.ajax.post;
let config = this.owner.lookup('service:config');
let feature = this.owner.lookup('service:feature');
let settings = this.owner.lookup('service:settings');
let tour = this.owner.lookup('service:tour');
return authenticator.authenticate('AzureDiamond', 'hunter2').then(() => {
expect(post.args[0][0]).to.equal('/ghost/api/v2/admin/session');
expect(post.args[0][1]).to.deep.include({
@ -50,6 +87,12 @@ describe('Unit: Authenticator: cookie', () => {
expect(post.args[0][1]).to.deep.include({
contentType: 'application/json;charset=utf-8'
});
// ensure our pre-loading calls have been made
expect(config.fetchAuthenticated.calledOnce, 'config.fetchAuthenticated called').to.be.true;
expect(feature.fetch.calledOnce, 'feature.fetch called').to.be.true;
expect(settings.fetch.calledOnce, 'settings.fetch called').to.be.true;
expect(tour.fetchViewed.calledOnce, 'tour.fetchViewed called').to.be.true;
});
});
});