mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 22:43:30 +03:00
🎨 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:
parent
083a8c054f
commit
457a8e2955
@ -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() {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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.');
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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() {
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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"}}
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user