From 3ae3e8142a5fdd44c5763cbe48be79a0c7b2219c Mon Sep 17 00:00:00 2001 From: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Tue, 8 Mar 2022 17:30:46 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Redesigned=20user=20authenticati?= =?UTF-8?q?on=20pages=20(#2286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs https://www.notion.so/ghost/Invite-staff-users-steps-in-setup-guide-367737e13d97450a98a0f39ec6b68181 * Simplified the selfhoster setup flow to one setup page only * Redesigned the reset password pages and the signup page for new staff members Co-authored-by: Daniel Lockyer --- ghost/admin/.lint-todo | 15 + ghost/admin/.template-lintrc.js | 2 +- .../components/gh-activating-list-item.hbs | 1 - .../app/components/gh-activating-list-item.js | 24 -- ghost/admin/app/controllers/setup.js | 198 +++++++++++- ghost/admin/app/controllers/setup/three.js | 253 ---------------- ghost/admin/app/controllers/setup/two.js | 196 ------------ ghost/admin/app/controllers/signin.js | 12 - ghost/admin/app/helpers/site-icon-style.js | 14 + ghost/admin/app/router.js | 6 +- ghost/admin/app/routes/setup.js | 4 +- ghost/admin/app/routes/setup/three.js | 10 - ghost/admin/app/styles/layouts/auth.css | 146 +-------- ghost/admin/app/styles/layouts/flow.css | 285 ++++++------------ ghost/admin/app/styles/patterns/forms.css | 6 + ghost/admin/app/templates/reset.hbs | 10 +- ghost/admin/app/templates/setup.hbs | 121 ++++++-- ghost/admin/app/templates/setup/one.hbs | 12 - ghost/admin/app/templates/setup/three.hbs | 40 --- ghost/admin/app/templates/setup/two.hbs | 95 ------ ghost/admin/app/templates/signin.hbs | 17 +- ghost/admin/app/templates/signup.hbs | 23 +- .../public/assets/icons/locked-email-back.svg | 1 - .../assets/icons/locked-email-front.svg | 1 - .../public/assets/icons/locked-email-lock.svg | 1 - ghost/admin/public/assets/img/ghost-logo.png | Bin 6516 -> 0 bytes .../tests/acceptance/authentication-test.js | 2 +- ghost/admin/tests/acceptance/setup-test.js | 212 ++----------- ghost/admin/tests/acceptance/signup-test.js | 2 +- 29 files changed, 464 insertions(+), 1245 deletions(-) delete mode 100644 ghost/admin/app/components/gh-activating-list-item.hbs delete mode 100644 ghost/admin/app/components/gh-activating-list-item.js delete mode 100644 ghost/admin/app/controllers/setup/three.js delete mode 100644 ghost/admin/app/controllers/setup/two.js create mode 100644 ghost/admin/app/helpers/site-icon-style.js delete mode 100644 ghost/admin/app/routes/setup/three.js delete mode 100644 ghost/admin/app/templates/setup/one.hbs delete mode 100644 ghost/admin/app/templates/setup/three.hbs delete mode 100644 ghost/admin/app/templates/setup/two.hbs delete mode 100644 ghost/admin/public/assets/icons/locked-email-back.svg delete mode 100644 ghost/admin/public/assets/icons/locked-email-front.svg delete mode 100644 ghost/admin/public/assets/icons/locked-email-lock.svg delete mode 100644 ghost/admin/public/assets/img/ghost-logo.png diff --git a/ghost/admin/.lint-todo b/ghost/admin/.lint-todo index ca07d77bf1..200dbc0dc2 100644 --- a/ghost/admin/.lint-todo +++ b/ghost/admin/.lint-todo @@ -1175,3 +1175,18 @@ remove|ember-template-lint|no-invalid-interactive|165|59|165|59|df9c2bf70a166edc remove|ember-template-lint|no-invalid-interactive|221|163|221|163|a5145bca59feb20e1078505aea2fd35758795d52|1646611200000|1649199600000|1651791600000|app/components/modal-portal-settings.hbs remove|ember-template-lint|no-invalid-interactive|235|60|235|60|50dc8bff0ff82f6924e528d71f114326cfed17ab|1646611200000|1649199600000|1651791600000|app/components/modal-portal-settings.hbs remove|ember-template-lint|no-invalid-interactive|280|59|280|59|76d4aa1f6519b5257d8809ab05cf5d34319bcac3|1646611200000|1649199600000|1651791600000|app/components/modal-portal-settings.hbs +remove|ember-template-lint|style-concatenation|24|46|24|46|f0aa84093e7f6b05f31e553bbb8042e19011c841|1646611200000|1649199600000|1651791600000|app/templates/signin.hbs +remove|ember-template-lint|no-action|10|78|10|78|95d57bb1a3a47d94a196cb0c0daa117fa681892a|1646611200000|1649199600000|1651791600000|app/templates/signup.hbs +add|ember-template-lint|no-action|21|31|21|31|3aa834e53af871a821ebb1b47f7a04f925c2b593|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-action|22|35|22|35|ae65e93ed2b79a307e0eba50b154fe84ee1ae210|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-action|39|31|39|31|eefdee43411bdd45c2786b9e826e6b550fd6212d|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-action|40|35|40|35|a8e4bd4c57a8f2df65749b0cfa4349da22b2a0aa|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-action|58|31|58|31|1709109776bf3fc47aaecc21e6d3ec8a0489ad6b|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-action|59|35|59|35|8000241a81189d3876d264331ec0c9b6476df076|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-action|77|31|77|31|6756e119daad4aa143724e9b56a6aef744d65251|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-action|78|35|78|35|26b1d89c0e5bbcd629a215903c0819d35f798aa6|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-passed-in-event-handlers|21|24|21|24|f0b7babba7593639d68dadae2f19e7144dd8c63e|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-passed-in-event-handlers|39|24|39|24|2692530760cb1f7156dcf9570aacf0f8baca2770|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-passed-in-event-handlers|58|24|58|24|ce0d7e2e732b22ce3643fb9b1ac6ecef07c275ff|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-passed-in-event-handlers|77|24|77|24|80681fcec2258c3d81a231bbd635aa1f03ff1452|1646697600000|1649286000000|1651878000000|app/templates/setup.hbs +add|ember-template-lint|no-duplicate-landmark-elements|16|16|16|16|1661d2edb187b634c8187e5ecb0db15a4c7262fc|1646697600000|1649286000000|1651878000000|app/templates/signin.hbs diff --git a/ghost/admin/.template-lintrc.js b/ghost/admin/.template-lintrc.js index 44383794ed..fa084ba0e7 100644 --- a/ghost/admin/.template-lintrc.js +++ b/ghost/admin/.template-lintrc.js @@ -3,7 +3,7 @@ module.exports = { rules: { 'no-forbidden-elements': ['meta', 'html', 'script'], - 'no-implicit-this': {allow: ['now']}, + 'no-implicit-this': {allow: ['now', 'site-icon-style']}, 'no-inline-styles': false } }; diff --git a/ghost/admin/app/components/gh-activating-list-item.hbs b/ghost/admin/app/components/gh-activating-list-item.hbs deleted file mode 100644 index 58d82aa610..0000000000 --- a/ghost/admin/app/components/gh-activating-list-item.hbs +++ /dev/null @@ -1 +0,0 @@ -{{this.title}}{{yield}} diff --git a/ghost/admin/app/components/gh-activating-list-item.js b/ghost/admin/app/components/gh-activating-list-item.js deleted file mode 100644 index 29bb562ed8..0000000000 --- a/ghost/admin/app/components/gh-activating-list-item.js +++ /dev/null @@ -1,24 +0,0 @@ -import Component from '@ember/component'; -import classic from 'ember-classic-decorator'; -import {action} from '@ember/object'; -import {classNameBindings, tagName} from '@ember-decorators/component'; -import {schedule} from '@ember/runloop'; - -@classic -@classNameBindings('active') -@tagName('li') -export default class GhActivatingListItem extends Component { - active = false; - linkClasses = null; - - @action - setActive(value) { - schedule('afterRender', this, function () { - this.set('active', value); - }); - } - - click() { - this.element.querySelector('a').blur(); - } -} diff --git a/ghost/admin/app/controllers/setup.js b/ghost/admin/app/controllers/setup.js index e58eeb3644..56743e60bc 100644 --- a/ghost/admin/app/controllers/setup.js +++ b/ghost/admin/app/controllers/setup.js @@ -1,22 +1,198 @@ import classic from 'ember-classic-decorator'; -import {computed} from '@ember/object'; -import {match} from '@ember/object/computed'; import {inject as service} from '@ember/service'; -/* eslint-disable ghost/ember/alias-model-in-controller */ -import Controller from '@ember/controller'; +/* eslint-disable camelcase, ghost/ember/alias-model-in-controller */ +import Controller, {inject as controller} from '@ember/controller'; +import ValidationEngine from 'ghost-admin/mixins/validation-engine'; +import {action, get} from '@ember/object'; +import {htmlSafe} from '@ember/template'; +import {isInvalidError} from 'ember-ajax/errors'; +import {isVersionMismatchError} from 'ghost-admin/services/ajax'; +import {task} from 'ember-concurrency'; @classic -export default class SetupController extends Controller { +export default class SetupController extends Controller.extend(ValidationEngine) { + @controller application; + + @service ajax; + @service config; @service ghostPaths; + @service notifications; @service router; + @service session; - @match('router.currentRouteName', /^setup\.(two|three)$/) - showBackLink; + // ValidationEngine settings + validationType = 'setup'; - @computed('router.currentRouteName') - get backRoute() { - let currentRoute = this.router.currentRouteName; + blogCreated = false; + blogTitle = null; + email = ''; + flowErrors = ''; + profileImage = null; + name = null; + password = null; - return currentRoute === 'setup.two' ? 'setup.one' : 'setup.two'; + @action + setup() { + this.setupTask.perform(); + } + + @action + preValidate(model) { + // Only triggers validation if a value has been entered, preventing empty errors on focusOut + if (this.get(model)) { + return this.validate({property: model}); + } + } + + @action + setImage(image) { + this.set('profileImage', image); + } + + @task(function* () { + return yield this._passwordSetup(); + }) + setupTask; + + @task(function* (authStrategy, authentication) { + // we don't want to redirect after sign-in during setup + this.session.skipAuthSuccessHandler = true; + + try { + let authResult = yield this.session + .authenticate(authStrategy, ...authentication); + + this.errors.remove('session'); + + return authResult; + } catch (error) { + if (error && error.payload && error.payload.errors) { + if (isVersionMismatchError(error)) { + return this.notifications.showAPIError(error); + } + + error.payload.errors.forEach((err) => { + err.message = htmlSafe(err.message); + }); + + this.set('flowErrors', error.payload.errors[0].message.string); + } else { + // Connection errors don't return proper status message, only req.body + this.notifications.showAlert('There was a problem on the server.', {type: 'error', key: 'session.authenticate.failed'}); + } + } + }) + authenticate; + + /** + * Uploads the given data image, then sends the changed user image property to the server + * @param {Object} user User object, returned from the 'setup' api call + * @return {RSVP.Promise} A promise that takes care of both calls + */ + _sendImage(user) { + let formData = new FormData(); + let imageFile = this.profileImage; + let uploadUrl = this.get('ghostPaths.url').api('images', 'upload'); + + formData.append('file', imageFile, imageFile.name); + formData.append('purpose', 'profile_image'); + + return this.ajax.post(uploadUrl, { + data: formData, + processData: false, + contentType: false, + dataType: 'text' + }).then((response) => { + let [image] = get(JSON.parse(response), 'images'); + let imageUrl = image.url; + let usersUrl = this.get('ghostPaths.url').api('users', user.id.toString()); + user.profile_image = imageUrl; + + return this.ajax.put(usersUrl, { + data: { + users: [user] + } + }); + }); + } + + _passwordSetup() { + let setupProperties = ['blogTitle', 'name', 'email', 'password']; + let data = this.getProperties(setupProperties); + let config = this.config; + let method = this.blogCreated ? 'put' : 'post'; + + this.set('flowErrors', ''); + + this.hasValidated.addObjects(setupProperties); + + return this.validate().then(() => { + let authUrl = this.get('ghostPaths.url').api('authentication', 'setup'); + + return this.ajax[method](authUrl, { + data: { + setup: [{ + name: data.name, + email: data.email, + password: data.password, + blogTitle: data.blogTitle + }] + } + }).then((result) => { + config.set('blogTitle', data.blogTitle); + + // don't try to login again if we are already logged in + if (this.get('session.isAuthenticated')) { + return this._afterAuthentication(result); + } + + // Don't call the success handler, otherwise we will be redirected to admin + this.session.skipAuthSuccessHandler = true; + + return this.session.authenticate('authenticator:cookie', data.email, data.password).then(() => { + this.set('blogCreated', true); + return this.session.populateUser().then(() => { + return this._afterAuthentication(result); + }); + }).catch((error) => { + this._handleAuthenticationError(error); + }); + }).catch((error) => { + this._handleSaveError(error); + }); + }).catch(() => { + this.set('flowErrors', 'Please fill out every field correctly to set up your blog.'); + }); + } + + _handleSaveError(resp) { + if (isInvalidError(resp)) { + let [error] = resp.payload.errors; + this.set('flowErrors', [error.message, error.context].join(' ')); + } else { + this.notifications.showAPIError(resp, {key: 'setup.blog-details'}); + } + } + + _handleAuthenticationError(error) { + if (error && error.payload && error.payload.errors) { + let [apiError] = error.payload.errors; + this.set('flowErrors', [apiError.message, apiError.context].join(' ')); + } else { + // Connection errors don't return proper status message, only req.body + this.notifications.showAlert('There was a problem on the server.', {type: 'error', key: 'setup.authenticate.failed'}); + } + } + + _afterAuthentication(result) { + if (this.profileImage) { + return this._sendImage(result.users[0]) + .then(() => (this.router.transitionTo('dashboard'))) + .catch((resp) => { + this.notifications.showAPIError(resp, {key: 'setup.blog-details'}); + }); + } else { + return this.router.transitionTo('dashboard'); + } } } diff --git a/ghost/admin/app/controllers/setup/three.js b/ghost/admin/app/controllers/setup/three.js deleted file mode 100644 index 6bfcd7cb7f..0000000000 --- a/ghost/admin/app/controllers/setup/three.js +++ /dev/null @@ -1,253 +0,0 @@ -/* eslint-disable ghost/ember/alias-model-in-controller */ -import Controller, {inject as controller} from '@ember/controller'; -// TODO: remove usage of Ember Data's private `Errors` class when refactoring validations -// eslint-disable-next-line -import DS from 'ember-data'; -import Ember from 'ember'; -import RSVP from 'rsvp'; -import validator from 'validator'; -import {alias} from '@ember/object/computed'; -import {computed} from '@ember/object'; -import {A as emberA} from '@ember/array'; -import {htmlSafe} from '@ember/template'; -import {isInvalidError} from 'ember-ajax/errors'; -import {run} from '@ember/runloop'; -import {inject as service} from '@ember/service'; -import {task, timeout} from 'ember-concurrency'; - -const {Errors} = DS; - -export default Controller.extend({ - two: controller('setup/two'), - - modals: service(), - notifications: service(), - router: service(), - session: service(), - - users: '', - - errors: Errors.create(), - hasValidated: emberA(), - ownerEmail: alias('two.email'), - - usersArray: computed('users', function () { - let errors = this.errors; - let users = this.users.split('\n').filter(function (email) { - return email.trim().length > 0; - }); - - // remove "no users to invite" error if we have users - if (users.uniq().length > 0 && errors.get('users.length') === 1) { - if (errors.get('users.firstObject').message.match(/no users/i)) { - errors.remove('users'); - } - } - - return users.uniq(); - }), - - validUsersArray: computed('usersArray', 'ownerEmail', function () { - let ownerEmail = this.ownerEmail; - - return this.usersArray.filter(function (user) { - return validator.isEmail(user || '') && user !== ownerEmail; - }); - }), - - invalidUsersArray: computed('usersArray', 'ownerEmail', function () { - let ownerEmail = this.ownerEmail; - - return this.usersArray.reject(user => validator.isEmail(user || '') || user === ownerEmail); - }), - - validationResult: computed('invalidUsersArray', function () { - let errors = []; - - this.invalidUsersArray.forEach((user) => { - errors.push({ - user, - error: 'email' - }); - }); - - if (errors.length === 0) { - // ensure we aren't highlighting fields when everything is fine - this.errors.clear(); - return true; - } else { - return errors; - } - }), - - buttonText: computed('errors.users', 'validUsersArray', 'invalidUsersArray', function () { - let usersError = this.get('errors.users.firstObject.message'); - let validNum = this.validUsersArray.length; - let invalidNum = this.invalidUsersArray.length; - let userCount; - - if (usersError && usersError.match(/no users/i)) { - return usersError; - } - - if (invalidNum > 0) { - userCount = invalidNum === 1 ? 'email address' : 'email addresses'; - return `${invalidNum} invalid ${userCount}`; - } - - if (validNum > 0) { - userCount = validNum === 1 ? 'user' : 'users'; - userCount = `${validNum} ${userCount}`; - } else { - userCount = 'some users'; - } - - return `Invite ${userCount}`; - }), - - buttonClass: computed('validationResult', 'usersArray.length', function () { - if (this.validationResult === true && this.get('usersArray.length') > 0) { - return 'gh-btn-green'; - } else { - return 'gh-btn-minor'; - } - }), - - authorRole: computed(function () { - return this.store.findAll('role', {reload: true}).then(roles => roles.findBy('name', 'Author')); - }), - - actions: { - validate() { - this.validate(); - }, - - invite() { - this.invite.perform(); - }, - - skipInvite() { - this.session.loadServerNotifications(); - this.modals.open('modals/get-started'); - this.router.transitionTo('home'); - } - }, - - validate() { - let errors = this.errors; - let validationResult = this.validationResult; - let property = 'users'; - - errors.clear(); - - // If property isn't in the `hasValidated` array, add it to mark that this field can show a validation result - this.hasValidated.addObject(property); - - if (validationResult === true) { - return true; - } - - validationResult.forEach((error) => { - // Only one error type here so far, but one day the errors might be more detailed - switch (error.error) { - case 'email': - errors.add(property, `${error.user} is not a valid email.`); - } - }); - - return false; - }, - - _transitionAfterSubmission() { - if (!this._hasTransitioned) { - this._hasTransitioned = true; - this.modals.open('modals/get-started'); - this.router.transitionTo('home'); - } - }, - - invite: task(function* () { - let users = this.validUsersArray; - - if (this.validate() && users.length > 0) { - this._hasTransitioned = false; - - this._slowSubmissionTimeout.perform(); - - let authorRole = yield this.authorRole; - let invites = yield this._saveInvites(authorRole); - - this._slowSubmissionTimeout.cancelAll(); - - this._showNotifications(invites); - - run.schedule('actions', this, function () { - this.session.loadServerNotifications(); - this._transitionAfterSubmission(); - }); - } else if (users.length === 0) { - this.errors.add('users', 'No users to invite'); - } - }).drop(), - - _slowSubmissionTimeout: task(function* () { - yield timeout(4000); - this._transitionAfterSubmission(); - }).drop(), - - _saveInvites(authorRole) { - let users = this.validUsersArray; - - return RSVP.Promise.all( - users.map((user) => { - let invite = this.store.createRecord('invite', { - email: user, - role: authorRole - }); - - return invite.save().then(() => ({ - email: user, - success: invite.get('status') === 'sent' - })).catch(error => ({ - error, - email: user, - success: false - })); - }) - ); - }, - - _showNotifications(invites) { - let notifications = this.notifications; - let erroredEmails = []; - let successCount = 0; - let invitationsString, message; - - invites.forEach((invite) => { - if (invite.success) { - successCount += 1; - } else if (isInvalidError(invite.error)) { - message = `${invite.email} was invalid: ${invite.error.payload.errors[0].message}`; - notifications.showAlert(message, {type: 'error', delayed: true, key: `signup.send-invitations.${invite.email}`}); - } else { - erroredEmails.push(invite.email); - } - }); - - if (erroredEmails.length > 0) { - invitationsString = erroredEmails.length > 1 ? ' invitations: ' : ' invitation: '; - message = `Failed to send ${erroredEmails.length} ${invitationsString}`; - message += Ember.Handlebars.Utils.escapeExpression(erroredEmails.join(', ')); - message += '. Please check your email configuration, see https://ghost.org/docs/config/#mail for instructions'; - - message = htmlSafe(message); - notifications.showAlert(message, {type: 'error', delayed: successCount > 0, key: 'signup.send-invitations.failed'}); - } - - if (successCount > 0) { - // pluralize - invitationsString = successCount > 1 ? 'invitations' : 'invitation'; - notifications.showAlert(`${successCount} ${invitationsString} sent!`, {type: 'success', delayed: true, key: 'signup.send-invitations.success'}); - } - } -}); diff --git a/ghost/admin/app/controllers/setup/two.js b/ghost/admin/app/controllers/setup/two.js deleted file mode 100644 index 75a59efc20..0000000000 --- a/ghost/admin/app/controllers/setup/two.js +++ /dev/null @@ -1,196 +0,0 @@ -import classic from 'ember-classic-decorator'; -import {inject as service} from '@ember/service'; -/* eslint-disable camelcase, ghost/ember/alias-model-in-controller */ -import Controller, {inject as controller} from '@ember/controller'; -import ValidationEngine from 'ghost-admin/mixins/validation-engine'; -import {action, get} from '@ember/object'; -import {htmlSafe} from '@ember/template'; -import {isInvalidError} from 'ember-ajax/errors'; -import {isVersionMismatchError} from 'ghost-admin/services/ajax'; -import {task} from 'ember-concurrency'; - -@classic -export default class TwoController extends Controller.extend(ValidationEngine) { - @controller application; - - @service ajax; - @service config; - @service ghostPaths; - @service notifications; - @service router; - @service session; - - // ValidationEngine settings - validationType = 'setup'; - - blogCreated = false; - blogTitle = null; - email = ''; - flowErrors = ''; - profileImage = null; - name = null; - password = null; - - @action - setup() { - this.setupTask.perform(); - } - - @action - preValidate(model) { - // Only triggers validation if a value has been entered, preventing empty errors on focusOut - if (this.get(model)) { - return this.validate({property: model}); - } - } - - @action - setImage(image) { - this.set('profileImage', image); - } - - @task(function* () { - return yield this._passwordSetup(); - }) - setupTask; - - @task(function* (authStrategy, authentication) { - // we don't want to redirect after sign-in during setup - this.session.skipAuthSuccessHandler = true; - - try { - let authResult = yield this.session - .authenticate(authStrategy, ...authentication); - - this.errors.remove('session'); - - return authResult; - } catch (error) { - if (error && error.payload && error.payload.errors) { - if (isVersionMismatchError(error)) { - return this.notifications.showAPIError(error); - } - - error.payload.errors.forEach((err) => { - err.message = htmlSafe(err.message); - }); - - this.set('flowErrors', error.payload.errors[0].message.string); - } else { - // Connection errors don't return proper status message, only req.body - this.notifications.showAlert('There was a problem on the server.', {type: 'error', key: 'session.authenticate.failed'}); - } - } - }) - authenticate; - - /** - * Uploads the given data image, then sends the changed user image property to the server - * @param {Object} user User object, returned from the 'setup' api call - * @return {RSVP.Promise} A promise that takes care of both calls - */ - _sendImage(user) { - let formData = new FormData(); - let imageFile = this.profileImage; - let uploadUrl = this.get('ghostPaths.url').api('images', 'upload'); - - formData.append('file', imageFile, imageFile.name); - formData.append('purpose', 'profile_image'); - - return this.ajax.post(uploadUrl, { - data: formData, - processData: false, - contentType: false, - dataType: 'text' - }).then((response) => { - let [image] = get(JSON.parse(response), 'images'); - let imageUrl = image.url; - let usersUrl = this.get('ghostPaths.url').api('users', user.id.toString()); - user.profile_image = imageUrl; - - return this.ajax.put(usersUrl, { - data: { - users: [user] - } - }); - }); - } - - _passwordSetup() { - let setupProperties = ['blogTitle', 'name', 'email', 'password']; - let data = this.getProperties(setupProperties); - let config = this.config; - let method = this.blogCreated ? 'put' : 'post'; - - this.set('flowErrors', ''); - - this.hasValidated.addObjects(setupProperties); - - return this.validate().then(() => { - let authUrl = this.get('ghostPaths.url').api('authentication', 'setup'); - - return this.ajax[method](authUrl, { - data: { - setup: [{ - name: data.name, - email: data.email, - password: data.password, - blogTitle: data.blogTitle - }] - } - }).then((result) => { - config.set('blogTitle', data.blogTitle); - - // don't try to login again if we are already logged in - if (this.get('session.isAuthenticated')) { - return this._afterAuthentication(result); - } - - // Don't call the success handler, otherwise we will be redirected to admin - this.session.skipAuthSuccessHandler = true; - - return this.session.authenticate('authenticator:cookie', data.email, data.password).then(() => { - this.set('blogCreated', true); - return this._afterAuthentication(result); - }).catch((error) => { - this._handleAuthenticationError(error); - }); - }).catch((error) => { - this._handleSaveError(error); - }); - }).catch(() => { - this.set('flowErrors', 'Please fill out the form to setup your blog.'); - }); - } - - _handleSaveError(resp) { - if (isInvalidError(resp)) { - let [error] = resp.payload.errors; - this.set('flowErrors', [error.message, error.context].join(' ')); - } else { - this.notifications.showAPIError(resp, {key: 'setup.blog-details'}); - } - } - - _handleAuthenticationError(error) { - if (error && error.payload && error.payload.errors) { - let [apiError] = error.payload.errors; - this.set('flowErrors', [apiError.message, apiError.context].join(' ')); - } else { - // Connection errors don't return proper status message, only req.body - this.notifications.showAlert('There was a problem on the server.', {type: 'error', key: 'setup.authenticate.failed'}); - } - } - - _afterAuthentication(result) { - if (this.profileImage) { - return this._sendImage(result.users[0]) - .then(() => (this.router.transitionTo('setup.three'))) - .catch((resp) => { - this.notifications.showAPIError(resp, {key: 'setup.blog-details'}); - }); - } else { - return this.router.transitionTo('setup.three'); - } - } -} diff --git a/ghost/admin/app/controllers/signin.js b/ghost/admin/app/controllers/signin.js index bf747a0f34..c5be605ad1 100644 --- a/ghost/admin/app/controllers/signin.js +++ b/ghost/admin/app/controllers/signin.js @@ -48,18 +48,6 @@ export default class SigninController extends Controller.extend(ValidationEngine return color; } - @computed('config.icon') - get siteIconStyle() { - let icon = this.get('config.icon'); - - if (icon) { - return htmlSafe(`background-image: url(${icon})`); - } - - icon = 'https://static.ghost.org/v4.0.0/images/ghost-orb-2.png'; - return htmlSafe(`background-image: url(${icon})`); - } - @action authenticate() { return this.validateAndAuthenticate.perform(); diff --git a/ghost/admin/app/helpers/site-icon-style.js b/ghost/admin/app/helpers/site-icon-style.js new file mode 100644 index 0000000000..7842aa1035 --- /dev/null +++ b/ghost/admin/app/helpers/site-icon-style.js @@ -0,0 +1,14 @@ +import Helper from '@ember/component/helper'; +import classic from 'ember-classic-decorator'; +import {htmlSafe} from '@ember/template'; +import {inject as service} from '@ember/service'; + +@classic +export default class SiteIconStyleHelper extends Helper { + @service config; + + compute() { + const icon = this.get('config.icon') || 'https://static.ghost.org/v4.0.0/images/ghost-orb-2.png'; + return htmlSafe(`background-image: url(${icon})`); + } +} \ No newline at end of file diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index ddc59b7df6..c6a2094772 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -10,11 +10,7 @@ const Router = EmberRouter.extend({ Router.map(function () { this.route('home', {path: '/'}); - this.route('setup', function () { - this.route('one'); - this.route('two'); - this.route('three'); - }); + this.route('setup'); this.route('setup.done', {path: '/setup/done'}); this.route('signin'); diff --git a/ghost/admin/app/routes/setup.js b/ghost/admin/app/routes/setup.js index 19d3d0fa15..8a7114dca5 100644 --- a/ghost/admin/app/routes/setup.js +++ b/ghost/admin/app/routes/setup.js @@ -26,7 +26,7 @@ export default class SetupRoute extends Route { if (setup.status) { return this.transitionTo('signin'); } else { - let controller = this.controllerFor('setup/two'); + let controller = this.controllerFor('setup'); if (setup.title) { controller.set('blogTitle', setup.title.replace(/'/gim, '\'')); } @@ -44,7 +44,7 @@ export default class SetupRoute extends Route { deactivate() { super.deactivate(...arguments); - this.controllerFor('setup/two').set('password', ''); + this.controllerFor('setup').set('password', ''); } buildRouteInfoMetadata() { diff --git a/ghost/admin/app/routes/setup/three.js b/ghost/admin/app/routes/setup/three.js deleted file mode 100644 index 81dc945aee..0000000000 --- a/ghost/admin/app/routes/setup/three.js +++ /dev/null @@ -1,10 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class ThreeRoute extends Route { - beforeModel() { - super.beforeModel(...arguments); - if (!this.controllerFor('setup.two').get('blogCreated')) { - this.transitionTo('setup.two'); - } - } -} diff --git a/ghost/admin/app/styles/layouts/auth.css b/ghost/admin/app/styles/layouts/auth.css index 470a2e8dea..9e76d4c2b0 100644 --- a/ghost/admin/app/styles/layouts/auth.css +++ b/ghost/admin/app/styles/layouts/auth.css @@ -2,19 +2,17 @@ /* ---------------------------------------------------------- */ .gh-signin, .gh-auth-email { position: relative; - margin: 30px auto; - padding: 40px; - max-width: 620px; width: 100%; - text-align: left; } +.gh-flow-content header, .gh-signin header { display: flex; flex-direction: column; align-items: center; } +.gh-flow-content .gh-site-icon, .gh-signin .gh-site-icon { margin-bottom: 20px; width: 70px; @@ -46,7 +44,9 @@ } .gh-signin .gh-btn-login, -.gh-signin .gh-btn-reset { +.gh-signin .gh-btn-reset, +.gh-setup .gh-btn-signup, +.gh-signup .gh-btn-signup { height: 54px; border-radius: 8px; line-height: 54px; @@ -58,7 +58,9 @@ } .gh-signin .gh-btn-login span, -.gh-signin .gh-btn-reset span { +.gh-signin .gh-btn-reset span, +.gh-setup .gh-btn-signup span, +.gh-signup .gh-btn-signup span { font-size: 1.8rem; color: #fff; } @@ -129,140 +131,18 @@ font-size: 1.8rem; } -.gh-signin input:focus { +.gh-signin .gh-input:focus:not(.gh-signin .gh-input.reset-password:focus) { border-color: var(--midgrey) !important; + box-shadow: none !important; } /* Email notification */ /* ---------------------------------------------------------- */ -.gh-auth-animation-container { - display: flex; - flex-direction: column; - align-items: center; - border-bottom: 1px solid var(--lightgrey); - animation: 0.5s forwards 0.6s containerFadeIn; - opacity: 0; -} -@keyframes containerFadeIn { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -.gh-auth-email-animation { - position: relative; - width: 122px; - height: 125px; - margin-bottom: -24px; - animation: 0.5s forwards 0.6s envelopeFadeIn; - opacity: 0; -} - -@keyframes envelopeFadeIn { - 0% { - transform: translateY(-6px); - opacity: 0; - } - 100% { - transform: translateY(0); - opacity: 1; - } -} - -.gh-auth-email-animation .gh-auth-envelope-back { - position: absolute; - top: 0; - left: 0; -} - -.gh-auth-email-animation .gh-auth-envelope-front { - position: absolute; - top: 48px; - left: 0; - z-index: 100; -} - -.gh-auth-email-animation .gh-auth-paper { - display: flex; - justify-content: center; - align-items: flex-start; - position: absolute; - top: 40px; - left: 15px; - width: 90px; - height: 82px; - background: white; - border: 1px solid #C5D2D9; - border-radius: 4px; - animation: 1.2s ease forwards 1.15s paperIn; -} - -.gh-auth-email-animation .gh-auth-lock { - margin-top: 15px; - width: 40px; - height: 40px; - opacity: 0; - animation: 0.45s forwards 1.35s lockIn; -} - -@keyframes paperIn { - 0% { - transform: scale(1,1) translateY(0); - } - 10% { - transform: scale(1.05,.95) translateY(0); - } - 30% { - transform: scale(.95,1.05) translateY(-32px); - } - 50% { - transform: scale(1,1) translateY(-27px); - } - 100% { - transform: scale(1,1) translateY(-27px); - } -} - -@keyframes lockIn { - 0% { - transform: scale(1) translateY(2px); - opacity: 0; - } - 60% { - transform: scale(1.1) translateY(-2px); - opacity: 1; - } - 100% { - transform: scale(1) translateY(0px); - opacity: 1; - } -} - -.gh-auth-lock-body { - margin-top: 48px; - animation: 0.5s forwards 0.2s bodyFadeIn; - opacity: 0; -} - -.gh-auth-lock-body p { - color: var(--midgrey); - margin: 0; - padding: 0; - font-size: 1.6rem; - font-weight: 400; - line-height: 1.4em; +.gh-auth-email header { text-align: center; } -@keyframes bodyFadeIn { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } +.gh-auth-email p { + color: var(--midgrey); } \ No newline at end of file diff --git a/ghost/admin/app/styles/layouts/flow.css b/ghost/admin/app/styles/layouts/flow.css index 9382059f3d..8bfc3569ca 100644 --- a/ghost/admin/app/styles/layouts/flow.css +++ b/ghost/admin/app/styles/layouts/flow.css @@ -7,7 +7,16 @@ flex-direction: column; overflow-y: auto; min-height: 100%; - background: linear-gradient(135deg, #fff, #f4f4f4); + background: linear-gradient(315deg,#efefef,#fff); +} + +.gh-setup { + align-items: center; + justify-content: center; + width: 100%; + min-height: unset; + overflow-y: unset; + padding: 0 2.4rem; } .gh-flow-head { @@ -28,163 +37,56 @@ padding-bottom: 8vh; } -.gh-flow-back { - position: absolute; - top: 0; - left: 0; - display: flex; - align-items: center; - margin: 0 0 0 3%; - padding: 2px 9px 2px 5px; - border: transparent 1px solid; - border-radius: 4px; - color: #7d878a; - font-weight: 300; - transition: all 0.3s ease; -} - -.gh-flow-back svg { - margin-right: 4px; - height: 12px; - line-height: 14px; -} - -.gh-flow-back svg path { - stroke: #7d878a; - stroke-width: 1.2px; -} - -.gh-flow-back:hover { - border: #dae1e3 1px solid; -} - -.gh-flow-nav { - position: relative; - flex: 1; -} - -.gh-flow-nav ol { - display: flex; - justify-content: space-between; - margin: 0 auto; - padding: 0; - width: 160px; - list-style: none; -} - -.gh-flow-nav li { - margin: 0; -} - -.gh-flow-nav .divider { - align-self: center; - width: 22px; - height: 2px; - background-image: linear-gradient(to right, var(--green) 33%, rgba(255, 255, 255, 0) 0%); - background-position: bottom; - background-size: 6px 2px; - background-repeat: repeat-x; -} - -.gh-flow-nav .active ~ .divider { - background-image: linear-gradient(to right, #e3e3e3 33%, rgba(255, 255, 255, 0) 0%); -} - -.gh-flow-nav .step { - display: flex; - justify-content: center; - align-items: center; - width: 30px; - height: 30px; - border: transparent 2px solid; - background: var(--green); - border-radius: 100%; - color: #fff; - vertical-align: middle; - text-align: center; - text-align: center; - font-size: 1.3rem; - line-height: 1; -} - -.gh-flow-nav .step .num { - display: none; -} - -.gh-flow-nav .step svg { - width: 26px; - height: 26px; - fill: #fff; - stroke: #fff; - stroke-width: 2px; -} - -.gh-flow-nav .step svg path { - stroke: #fff; -} - -.gh-flow-nav .active ~ li:not(divider) .step { - border: #e3e3e3 2px solid; - background: transparent; - color: #cdcdcd; -} - -.gh-flow-nav .active ~ li:not(divider) .step .num { - display: block; -} - -.gh-flow-nav .active ~ li:not(divider) .step svg { - display: none; -} - -.gh-flow-nav .active .step { - border: var(--green) 2px solid; - background: transparent; - color: color-mod(var(--green) lightness(-10%)); - cursor: default; -} - -.gh-flow-nav .active .step .num { - display: block; -} - -.gh-flow-nav .active .step svg { - display: none; -} - -.gh-flow-nav .done { - border: none; - background: var(--green); - color: #fff; -} - - .gh-flow-content { display: flex; flex-direction: column; - max-width: 700px; + max-width: 520px; width: 100%; - color: var(--midgrey); - text-align: center; + margin: 4.8rem 0 8rem; + color: var(--darkgrey); font-size: 1.9rem; line-height: 1.5em; font-weight: 300; } -@media (max-width: 500px) { +@media (max-width: 400px) { .gh-flow-content { + margin: 4rem 0 6rem; font-size: 4vw; } } -.gh-flow-content header { - margin: 0 auto; - max-width: 520px; +.gh-setup .gh-flow-content header { + margin: 0 0 1rem; +} + +.gh-setup .gh-flow-content header svg { + width: 7.2rem; + margin: 0 0 1rem; +} + +.gh-setup .gh-flow-content h1 { + margin-bottom: 0; +} + +.gh-setup .gh-flow-content p { + color: var(--midgrey); + font-size: 1.9rem; + font-weight: 400; + line-height: 1.4em; +} + +@media (max-width: 520px) { + .gh-setup .gh-flow-content p { + font-size: 1.7rem; + } } .gh-flow-content h1 { - font-size: 4.2rem; - font-weight: 300; + margin-bottom: 40px; + font-size: 4.1rem; + font-weight: 700; + line-height: 1.15em; } @media (max-width: 600px) { @@ -198,44 +100,27 @@ } .gh-flow-content em { - color: var(--blue); - font-weight: 400; + color: var(--black); + font-weight: 500; font-style: normal; } -.gh-flow-content .gh-flow-screenshot { - display: flex; - align-items: center; - margin: 0; - height: 45vh; -} - -.gh-flow-content .gh-flow-screenshot img { - position: relative; - left: -3%; - flex-shrink: 0; - display: block; - margin: 0 auto; - max-height: 100%; -} - -@media (max-width: 860px) { - .gh-flow-content .gh-flow-screenshot img { - left: 0; - } -} -@media (max-width: 600px) { - .gh-flow-content .gh-flow-screenshot { - height: auto; - } -} - .gh-flow-content .gh-btn { display: block; margin: 40px auto 0; max-width: 400px; } +.gh-setup .gh-flow-content .gh-btn { + height: 52px; + max-width: unset; + margin: 40px 0 0 +} + +.gh-setup .gh-flow-content .gh-btn span { + font-size: 1.7rem; +} + .gh-flow-content .login span { height: 37px !important; line-height: 37px !important; @@ -273,11 +158,6 @@ margin-left: 6px; } -.gh-flow-content .gh-input:focus { - box-shadow: none; - border-color: color-mod(var(--midlightgrey) l(+10%)); -} - .gh-flow-content .gh-flow-skip { display: inline-block; margin-top: 5px; @@ -298,11 +178,6 @@ box-shadow: 0 20px 45px -10px rgba(0, 0, 0, 0.1); } -.gh-flow-create .gh-btn-create-account span { - height: 37px; - line-height: 37px; -} - .gh-flow-content .account-image { position: absolute; top: -50px; @@ -395,9 +270,9 @@ } .gh-flow-content .form-group label { - margin: 0; + margin: 0 0 .3em; font-size: 1.4rem; - font-weight: 400; + font-weight: 600; } .gh-flow-content .form-group a { @@ -405,10 +280,17 @@ } .gh-flow-content input { - padding: 10px 10px 10px 30px; - font-size: 1.4rem; - line-height: 1.4em; + height: 48px; + padding: 12px 16px; + font-size: 1.7rem; font-weight: 400; + border-radius: 8px; +} + +.gh-flow-content .gh-input:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px rgba(26,170,96,.15); + outline: none; } .gh-flow-content .pw-strength { @@ -490,14 +372,14 @@ line-height: 1.8rem; } -.gh-flow-content .response { +.gh-flow-content .response, +.gh-setup .gh-flow-content .response { position: absolute; right: 0; - bottom: -25px; + bottom: -24px; margin: 0; color: #a6b0b3; - text-align: right; - font-size: 1.2rem; + font-size: 1.35rem; } .gh-flow-content form:not(.gh-signin) .success .gh-input-icon svg { @@ -511,21 +393,32 @@ font-weight: 400; } -.gh-flow-content .error input { +.gh-flow-content .error input, +.gh-flow-content .error input:focus { border-color: var(--red); - box-shadow: none; + box-shadow: 0 0 0 3px rgba(239,24,24,.15); } .gh-flow-content .error .gh-input-icon svg { fill: var(--red); } -.gh-flow-content .error .response { +.gh-flow-content .error .response, +.gh-setup .gh-flow-content .error .response { color: var(--red); } -.gh-flow-content .main-error { - margin-top: 5px; - color: var(--red); - font-size: 1.3rem; +.gh-flow-content .main-error, +.gh-setup .gh-flow-content .main-error { + margin-top: 8px; + color: var(--midgrey); + font-size: 1.35rem; + text-align: center; +} + +.gh-setup .gh-flow-form .gh-btn-red, +.gh-setup .gh-flow-form .gh-btn-red:active, +.gh-signup .gh-btn-red, +.gh-signup .gh-btn-red:active { + background: var(--black) !important; } diff --git a/ghost/admin/app/styles/patterns/forms.css b/ghost/admin/app/styles/patterns/forms.css index 807215a224..f8628f6574 100644 --- a/ghost/admin/app/styles/patterns/forms.css +++ b/ghost/admin/app/styles/patterns/forms.css @@ -57,6 +57,12 @@ input[type=number] { font-weight: 400; } +::-moz-placeholder { + color: var(--midlightgrey); + font-weight: 400; + opacity: 1; +} + .error .response { color: var(--red); } diff --git a/ghost/admin/app/templates/reset.hbs b/ghost/admin/app/templates/reset.hbs index b0e18304f6..d56d5deaf9 100644 --- a/ghost/admin/app/templates/reset.hbs +++ b/ghost/admin/app/templates/reset.hbs @@ -2,13 +2,17 @@
\ No newline at end of file diff --git a/ghost/admin/app/templates/setup/one.hbs b/ghost/admin/app/templates/setup/one.hbs deleted file mode 100644 index 680672e8ac..0000000000 --- a/ghost/admin/app/templates/setup/one.hbs +++ /dev/null @@ -1,12 +0,0 @@ -
-

Welcome to Ghost!

-

All over the world, people have started 2,000,000+ incredible sites with Ghost. Today, we’re starting yours.

-
- -
- Ghost screenshot -
- - - Create your account → - diff --git a/ghost/admin/app/templates/setup/three.hbs b/ghost/admin/app/templates/setup/three.hbs deleted file mode 100644 index e8a0c561e9..0000000000 --- a/ghost/admin/app/templates/setup/three.hbs +++ /dev/null @@ -1,40 +0,0 @@ -
-

Invite staff users

-

Ghost works best when shared with others. Collaborate, get feedback on your posts & work together on ideas.

-
- -
- -
- - - - - - - - {{#if task.isRunning}} - {{svg-jar "spinner" class="no-margin"}} - {{else}} - {{this.buttonText}} - {{/if}} - - -
- - diff --git a/ghost/admin/app/templates/setup/two.hbs b/ghost/admin/app/templates/setup/two.hbs deleted file mode 100644 index 0f18621888..0000000000 --- a/ghost/admin/app/templates/setup/two.hbs +++ /dev/null @@ -1,95 +0,0 @@ -
-

Create your account

-
- -
- - - - - - {{svg-jar "content"}} - - - - - - - - - {{svg-jar "user-circle"}} - - - - - - - - - {{svg-jar "email"}} - - - - - - - - - {{svg-jar "lock"}} - - - - - - - {{#if task.isRunning}} - {{svg-jar "spinner" class="gh-icon-spinner gh-btn-icon-no-margin"}} - {{else}} - Last step: Invite staff users → - {{/if}} - - - -

{{this.flowErrors}} 

diff --git a/ghost/admin/app/templates/signin.hbs b/ghost/admin/app/templates/signin.hbs index dae9dd3933..3c588d6065 100644 --- a/ghost/admin/app/templates/signin.hbs +++ b/ghost/admin/app/templates/signin.hbs @@ -3,25 +3,18 @@
{{#if this.passwordResetEmailSent}}
-
-
- {{svg-jar "locked-email-back" class="gh-auth-envelope-back"}} - {{svg-jar "locked-email-front" class="gh-auth-envelope-front"}} -
- {{svg-jar "locked-email-lock" class="gh-auth-lock"}} -
-
-
-
+
+
+

Update your password.

For security, you need to create a new password. An email has been sent to you with instructions!

-
+
{{else}} diff --git a/ghost/admin/public/assets/icons/locked-email-back.svg b/ghost/admin/public/assets/icons/locked-email-back.svg deleted file mode 100644 index d057913fff..0000000000 --- a/ghost/admin/public/assets/icons/locked-email-back.svg +++ /dev/null @@ -1 +0,0 @@ -envelope-back \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/locked-email-front.svg b/ghost/admin/public/assets/icons/locked-email-front.svg deleted file mode 100644 index a4dabc8c16..0000000000 --- a/ghost/admin/public/assets/icons/locked-email-front.svg +++ /dev/null @@ -1 +0,0 @@ -envelope-front \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/locked-email-lock.svg b/ghost/admin/public/assets/icons/locked-email-lock.svg deleted file mode 100644 index 30f7c6de8e..0000000000 --- a/ghost/admin/public/assets/icons/locked-email-lock.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/public/assets/img/ghost-logo.png b/ghost/admin/public/assets/img/ghost-logo.png deleted file mode 100644 index 4aef95b37dd9ba15ecfe49905b608eba021dadd9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6516 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