🎨 Redesigned user authentication pages (#2286)

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 <hi@daniellockyer.com>
This commit is contained in:
Sanne de Vries 2022-03-08 17:30:46 +00:00 committed by GitHub
parent e46a406645
commit 3ae3e8142a
29 changed files with 464 additions and 1245 deletions

View File

@ -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

View File

@ -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
}
};

View File

@ -1 +0,0 @@
<LinkTo @route={{this.route}} @alternateActive={{action "setActive"}} class={{@linkClasses}}>{{this.title}}{{yield}}</LinkTo>

View File

@ -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();
}
}

View File

@ -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');
}
}
}

View File

@ -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 <a href=\'https://ghost.org/docs/config/#mail\' target=\'_blank\'>https://ghost.org/docs/config/#mail</a> 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'});
}
}
});

View File

@ -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');
}
}
}

View File

@ -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();

View File

@ -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})`);
}
}

View File

@ -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');

View File

@ -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(/&apos;/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() {

View File

@ -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');
}
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -2,13 +2,17 @@
<div class="gh-flow-content-wrap">
<section class="gh-flow-content fade-in">
<form id="reset" class="gh-signin" method="post" novalidate="novalidate" {{action "submit" on="submit"}}>
<header>
<div class="gh-site-icon" style={{site-icon-style}}></div>
<h1>Reset your password.</h1>
</header>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="newPassword">
<span class="gh-input-icon gh-icon-lock">
<GhTextInput
@type="password"
@name="newpassword"
@placeholder="New password"
@class="password"
@class="password reset-password"
@autocorrect="off"
@shouldFocus={{true}}
@value={{readonly this.newPassword}}
@ -21,14 +25,14 @@
@type="password"
@name="ne2password"
@placeholder="Confirm new password"
@class="password"
@class="password reset-password"
@autocorrect="off"
@value={{readonly this.ne2Password}}
@input={{action (mut this.ne2Password) value="target.value"}} />
</span>
</GhFormGroup>
<GhTaskButton @buttonText="Save new password" @task={{this.resetPassword}} @class="gh-btn gh-btn-blue gh-btn-reset gh-btn-block gh-btn-icon" @type="submit" @autoWidth="false" />
<GhTaskButton @buttonText="Save new password" @task={{this.resetPassword}} @class="gh-btn gh-btn-reset gh-btn-block gh-btn-icon" @type="submit" @autoWidth="false" />
</form>
<p class="main-error">{{this.flowErrors}}&nbsp;</p>

View File

@ -1,27 +1,96 @@
<div class="gh-flow">
<header class="gh-flow-head">
<nav class="gh-flow-nav">
{{#if this.showBackLink}}
<LinkTo @route={{this.backRoute}} class="gh-flow-back">{{svg-jar "arrow-left-small"}} Back</LinkTo>
{{/if}}
<ol>
<GhActivatingListItem @route="setup.one" @linkClasses="step">
{{svg-jar "check-circle"}}<span class="num">1</span>
</GhActivatingListItem>
<li class="divider"></li>
<GhActivatingListItem @route="setup.two" @linkClasses="step">
{{svg-jar "check-circle"}}<span class="num">2</span>
</GhActivatingListItem>
<li class="divider"></li>
<GhActivatingListItem @route="setup.three" @linkClasses="step">
{{svg-jar "check-circle"}}<span class="num">3</span>
</GhActivatingListItem>
</ol>
</nav>
<section class="gh-flow gh-setup">
<div class="gh-flow-content">
<header>
{{svg-jar "ghost-orb" alt="Ghost"}}
<h1>Welcome to Ghost.</h1>
<p>All over the world, people have started 3,000,000+ incredible sites with Ghost. Today, were starting yours.</p>
</header>
<div class="gh-flow-content-wrap">
<section class="gh-flow-content">
{{outlet}}
<form id="setup" class="gh-flow-form">
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="blogTitle">
<label for="blog-title">Site title</label>
<span class="gh-input-icon gh-icon-content">
<GhTrimFocusInput
@tabindex="1"
@type="text"
@id="blog-title"
@name="blog-title"
@placeholder="The Daily Awesome"
@autocorrect="off"
@value={{readonly this.blogTitle}}
@input={{action (mut this.blogTitle) value="target.value"}}
@focus-out={{action "preValidate" "blogTitle"}}
data-test-blog-title-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="blogTitle" />
</GhFormGroup>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="name">
<label for="name">Full name</label>
<span class="gh-input-icon gh-icon-user">
<GhTextInput
@tabindex="2"
@id="name"
@name="name"
@placeholder="Jamie Larson"
@autocorrect="off"
@autocomplete="name"
@value={{readonly this.name}}
@input={{action (mut this.name) value="target.value"}}
@focus-out={{action "preValidate" "name"}}
data-test-name-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="name" />
</GhFormGroup>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="email">
<label for="email">Email address</label>
<span class="gh-input-icon gh-icon-mail">
<GhTextInput
@tabindex="3"
@type="email"
@id="email"
@name="email"
@placeholder="jamie@example.com"
@autocorrect="off"
@autocomplete="username email"
@value={{readonly this.email}}
@input={{action (mut this.email) value="target.value"}}
@focus-out={{action "preValidate" "email"}}
data-test-email-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="email" />
</GhFormGroup>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="password">
<label for="password">Password</label>
<span class="gh-input-icon gh-icon-lock">
<GhTextInput
@tabindex="4"
@type="password"
@id="password"
@name="password"
@placeholder="At least 10 characters"
@autocorrect="off"
@autocomplete="new-password"
@value={{readonly this.password}}
@input={{action (mut this.password) value="target.value"}}
@focus-out={{action "preValidate" "password"}}
data-test-password-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="password" />
</GhFormGroup>
<GhTaskButton @task={{this.setupTask}} @type="submit" @tabindex="5" @data-test-button="setup" @class="gh-btn gh-btn-black gh-btn-signup gh-btn-block gh-btn-icon" as |task|>
{{#if task.isRunning}}
<span>{{svg-jar "spinner" class="gh-icon-spinner gh-btn-icon-no-margin"}}</span>
{{else}}
<span>Create account & start publishing &rarr;</span>
{{/if}}
</GhTaskButton>
</form>
{{#if this.flowErrors}}
<p class="main-error">{{this.flowErrors}}&nbsp;</p>
{{/if}}
</div>
</section>
</div>
</div>

View File

@ -1,12 +0,0 @@
<header>
<h1>Welcome to <strong>Ghost</strong>!</h1>
<p>All over the world, people have started <em>2,000,000+</em> incredible sites with Ghost. Today, were starting yours.</p>
</header>
<figure class="gh-flow-screenshot">
<img src="assets/img/install-welcome.png" alt="Ghost screenshot" />
</figure>
<LinkTo @route="setup.two" class="gh-btn gh-btn-green gh-btn-lg gh-btn-icon gh-btn-icon-right">
<span>Create your account &rarr;</span>
</LinkTo>

View File

@ -1,40 +0,0 @@
<header>
<h1>Invite staff users</h1>
<p>Ghost works best when shared with others. Collaborate, get feedback on your posts &amp; work together on ideas.</p>
</header>
<div><img class="gh-flow-faces" src="assets/img/users.png" alt="" /></div>
<form class="gh-flow-invite" {{action "invite" on="submit"}}>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="users">
<label for="users">Enter one email address per line, well handle the rest! {{svg-jar "email"}}</label>
<GhTextarea
@name="users"
@required="required"
@value={{readonly this.users}}
@input={{action (mut this.users) value="target.value"}}
@focus-out={{action "validate"}}
/>
</GhFormGroup>
<GhTaskButton
@task={{this.invite}}
@type="submit"
@class="gh-btn gh-btn-default gh-btn-lg gh-btn-block {{this.buttonClass}}"
@successClass=""
@failureClass=""
as |task|
>
<span>
{{#if task.isRunning}}
{{svg-jar "spinner" class="no-margin"}}
{{else}}
{{this.buttonText}}
{{/if}}
</span>
</GhTaskButton>
</form>
<button class="gh-flow-skip" type="button" {{action "skipInvite"}}>
I'll do this later, take me to my site!
</button>

View File

@ -1,95 +0,0 @@
<header>
<h1>Create your account</h1>
</header>
<form id="setup" class="gh-flow-create">
<GhProfileImage @email={{this.email}} @setImage={{action "setImage"}} />
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="blogTitle">
<label for="blog-title">Site title</label>
<span class="gh-input-icon gh-icon-content">
{{svg-jar "content"}}
<GhTrimFocusInput
@tabindex="1"
@type="text"
@id="blog-title"
@name="blog-title"
@placeholder="Eg. The Daily Awesome"
@autocorrect="off"
@value={{readonly this.blogTitle}}
@input={{action (mut this.blogTitle) value="target.value"}}
@focus-out={{action "preValidate" "blogTitle"}}
data-test-blog-title-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="blogTitle" />
</GhFormGroup>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="name">
<label for="name">Full name</label>
<span class="gh-input-icon gh-icon-user">
{{svg-jar "user-circle"}}
<GhTextInput
@tabindex="2"
@id="name"
@name="name"
@placeholder="Eg. John H. Watson"
@autocorrect="off"
@autocomplete="name"
@value={{readonly this.name}}
@input={{action (mut this.name) value="target.value"}}
@focus-out={{action "preValidate" "name"}}
data-test-name-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="name" />
</GhFormGroup>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="email">
<label for="email">Email address</label>
<span class="gh-input-icon gh-icon-mail">
{{svg-jar "email"}}
<GhTextInput
@tabindex="3"
@type="email"
@id="email"
@name="email"
@placeholder="Eg. john@example.com"
@autocorrect="off"
@autocomplete="username email"
@value={{readonly this.email}}
@input={{action (mut this.email) value="target.value"}}
@focus-out={{action "preValidate" "email"}}
data-test-email-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="email" />
</GhFormGroup>
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="password">
<label for="password">Password</label>
<span class="gh-input-icon gh-icon-lock">
{{svg-jar "lock"}}
<GhTextInput
@tabindex="4"
@type="password"
@id="password"
@name="password"
@placeholder="At least 10 characters"
@autocorrect="off"
@autocomplete="new-password"
@value={{readonly this.password}}
@input={{action (mut this.password) value="target.value"}}
@focus-out={{action "preValidate" "password"}}
data-test-password-input={{true}} />
</span>
<GhErrorMessage @errors={{this.errors}} @property="password" />
</GhFormGroup>
<GhTaskButton @task={{this.setupTask}} @type="submit" @tabindex="5" @class="gh-btn gh-btn-green gh-btn-lg gh-btn-block gh-btn-icon" as |task|>
{{#if task.isRunning}}
<span>{{svg-jar "spinner" class="gh-icon-spinner gh-btn-icon-no-margin"}}</span>
{{else}}
<span>Last step: Invite staff users &rarr;</span>
{{/if}}
</GhTaskButton>
</form>
<p class="main-error">{{this.flowErrors}}&nbsp;</p>

View File

@ -3,25 +3,18 @@
<section class="gh-flow-content">
{{#if this.passwordResetEmailSent}}
<div class="gh-auth-email">
<div class="gh-auth-animation-container">
<div class="gh-auth-email-animation">
{{svg-jar "locked-email-back" class="gh-auth-envelope-back"}}
{{svg-jar "locked-email-front" class="gh-auth-envelope-front"}}
<div class="gh-auth-paper">
{{svg-jar "locked-email-lock" class="gh-auth-lock"}}
</div>
</div>
</div>
<div class="gh-auth-lock-body">
<header>
<div class="gh-site-icon" style={{site-icon-style}}></div>
<h1>Update your password.</h1>
<p>
For security, you need to create a new password. An email has been sent to you with instructions!
</p>
</div>
</header>
</div>
{{else}}
<form id="login" method="post" class="gh-signin" novalidate="novalidate" {{action "authenticate" on="submit"}}>
<header>
<div class="gh-site-icon" style="{{this.siteIconStyle}}"></div>
<div class="gh-site-icon" style={{site-icon-style}}></div>
<h1>Sign in to {{this.config.blogTitle}}.</h1>
</header>
{{#if this.config.oauth}}

View File

@ -3,22 +3,20 @@
<div class="gh-flow-content-wrap">
<section class="gh-flow-content">
<header>
<h1>Create your account</h1>
<div class="gh-site-icon" style={{site-icon-style}}></div>
<h1>Create your account.</h1>
</header>
<form id="signup" class="gh-flow-create" method="post" novalidate="novalidate" onsubmit={{action "submit"}}>
<GhProfileImage @email={{this.signupDetails.email}} @setImage={{action "setImage"}} />
<form id="signup" class="gh-signup" method="post" novalidate="novalidate" onsubmit={{action "submit"}}>
<GhFormGroup @errors={{this.signupDetails.errors}} @hasValidated={{this.signupDetails.hasValidated}} @property="name">
<label for="name">Full name</label>
<span class="gh-input-icon gh-icon-user">
{{svg-jar "user-circle"}}
<GhTrimFocusInput
@tabindex="1"
@type="text"
@id="display-name"
@name="display-name"
@placeholder="Eg. John H. Watson"
@placeholder="Jamie Larson"
@autocorrect="off"
@autocomplete="name"
@value={{readonly this.signupDetails.name}}
@ -33,13 +31,12 @@
<GhFormGroup @errors={{this.signupDetails.errors}} @hasValidated={{this.signupDetails.hasValidated}} @property="email">
<label for="email">Email address</label>
<span class="gh-input-icon gh-icon-mail">
{{svg-jar "email"}}
<GhTextInput
@tabindex="2"
@type="text"
@id="username"
@name="username"
@placeholder="Eg. john@example.com"
@placeholder="jamie@example.com"
@autocorrect="off"
@autocomplete="username email"
@value={{readonly this.signupDetails.email}}
@ -54,7 +51,6 @@
<GhFormGroup @errors={{this.signupDetails.errors}} @hasValidated={{this.signupDetails.hasValidated}} @property="password">
<label for="password">Password</label>
<span class="gh-input-icon gh-icon-lock">
{{svg-jar "lock"}}
<GhTextInput
@tabindex="3"
@type="password"
@ -72,11 +68,12 @@
<GhErrorMessage @errors={{this.signupDetails.errors}} @property="password" />
</GhFormGroup>
<GhTaskButton @buttonText="Create Account" @type="submit" @form="signup" @defaultClick={{true}} @runningText="Creating"
@task={{this.signup}} @class="gh-btn-create-account gh-btn gh-btn-green gh-btn-lg gh-btn-block gh-btn-icon" @tabindex="3" />
<GhTaskButton @buttonText="Create Account &rarr;" @type="submit" @form="signup" @defaultClick={{true}} @runningText="Creating"
@task={{this.signup}} @data-test-button="signup" @class="gh-btn gh-btn-black gh-btn-signup gh-btn-block gh-btn-icon" @tabindex="3" />
</form>
<p class="main-error">{{if this.flowErrors this.flowErrors}}&nbsp;</p>
{{#if this.flowErrors}}
<p class="main-error">{{this.flowErrors}}&nbsp;</p>
{{/if}}
</section>
</div>

View File

@ -1 +0,0 @@
<svg width="122" height="125" viewBox="0 0 122 125" xmlns="http://www.w3.org/2000/svg"><title>envelope-back</title><path d="M3.953 39.164L54.375 2.172c3.947-2.896 9.303-2.896 13.25 0l50.422 36.992c2.484 1.823 3.953 4.728 3.953 7.82v68.336c0 5.346-4.312 9.68-9.632 9.68H9.632C4.312 125 0 120.666 0 115.32V46.983c0-3.09 1.469-5.996 3.953-7.819z" fill="#C5D2D9" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 386 B

View File

@ -1 +0,0 @@
<svg width="122" height="77" viewBox="0 0 122 77" xmlns="http://www.w3.org/2000/svg"><title>envelope-front</title><g stroke="#C5D2D9" fill="none" fill-rule="evenodd"><path d="M.5 3.023v64.28c0 2.519 1.002 4.802 2.642 6.463 1.634 1.655 3.9 2.693 6.438 2.734h102.431c1.888-.005 3.641-.592 5.093-1.593l-5.64-3.368C44.768 31.699 9.41 10.472 5.388 7.859 3.085 6.365 1.463 4.75.5 3.024z" fill="#F2F4F7"/><path d="M121.491 3.111c-.97 1.746-2.609 3.38-4.94 4.89-3.73 2.418-34.423 20.824-92.079 55.216L4.895 74.89c1.428.99 3.162 1.578 5.127 1.61l102.41-.001c2.325-.006 4.447-.894 6.052-2.35 1.616-1.466 2.71-3.508 2.962-5.806l.045-65.233z" fill="#F8FAFC"/></g></svg>

Before

Width:  |  Height:  |  Size: 657 B

View File

@ -1 +0,0 @@
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"><path d="M8.75 16.25h22.5c1.667 0 2.5.833 2.5 2.5v17.5c0 1.667-.833 2.5-2.5 2.5H8.75c-1.667 0-2.5-.833-2.5-2.5v-17.5c0-1.667.833-2.5 2.5-2.5zm2.5 0V10c0-3.368 1.458-5.894 4.375-7.578 2.917-1.684 5.833-1.684 8.75 0S28.75 6.632 28.75 10v6.25M20 25v5" stroke="#3EB0EF" stroke-width="1.5" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -24,7 +24,7 @@ describe('Acceptance: Authentication', function () {
});
it('redirects to setup when setup isn\'t complete', async function () {
await visit('settings/labs');
expect(currentURL()).to.equal('/setup/one');
expect(currentURL()).to.equal('/setup');
});
});

View File

@ -1,8 +1,7 @@
import moment from 'moment';
import {Response} from 'miragejs';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {blur, click, currentURL, fillIn, find, findAll} from '@ember/test-helpers';
import {click, currentURL, fillIn, find, findAll} from '@ember/test-helpers';
import {enableLabsFlag} from '../helpers/labs-flag';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
@ -23,13 +22,7 @@ describe('Acceptance: Setup', function () {
await authenticateSession();
await visit('/setup/one');
expect(currentURL()).to.equal('/site');
await visit('/setup/two');
expect(currentURL()).to.equal('/site');
await visit('/setup/three');
await visit('/setup');
expect(currentURL()).to.equal('/site');
});
@ -67,30 +60,13 @@ describe('Acceptance: Setup', function () {
await visit('/setup');
// it redirects to step one
expect(currentURL(), 'url after accessing /setup')
.to.equal('/setup/one');
// it highlights first step
let stepIcons = findAll('.gh-flow-nav .step');
expect(stepIcons.length, 'sanity check: three steps').to.equal(3);
expect(stepIcons[0], 'first step').to.have.class('active');
expect(stepIcons[1], 'second step').to.not.have.class('active');
expect(stepIcons[2], 'third step').to.not.have.class('active');
await click('.gh-btn-green');
// it transitions to step two
expect(currentURL(), 'url after clicking "Create your account"')
.to.equal('/setup/two');
// email field is focused by default
// NOTE: $('x').is(':focus') doesn't work in phantomjs CLI runner
// https://github.com/ariya/phantomjs/issues/10427
expect(findAll('[data-test-blog-title-input]')[0] === document.activeElement, 'blog title has focus')
.to.be.true;
await click('.gh-btn-green');
await click('[data-test-button="setup"]');
// it marks fields as invalid
expect(findAll('.form-group.error').length, 'number of invalid fields')
@ -109,39 +85,14 @@ describe('Acceptance: Setup', function () {
await fillIn('[data-test-name-input]', 'Test User');
await fillIn('[data-test-password-input]', 'thisissupersafe');
await fillIn('[data-test-blog-title-input]', 'Blog Title');
await click('.gh-btn-green');
await click('[data-test-button="setup"]');
// it transitions to step 3
expect(currentURL(), 'url after submitting step two')
.to.equal('/setup/three');
// submit button is "disabled"
expect(find('button[type="submit"]').classList.contains('gh-btn-green'), 'invite button with no emails is white')
.to.be.false;
// fill in a valid email
await fillIn('[name="users"]', 'new-user@example.com');
// submit button is "enabled"
expect(find('button[type="submit"]').classList.contains('gh-btn-green'), 'invite button is green with valid email address')
.to.be.true;
// submit the invite form
await click('button[type="submit"]');
// it redirects to the home / "content" screen
expect(currentURL(), 'url after submitting invites')
// it redirects to the dashboard
expect(currentURL(), 'url after submitting account details')
.to.equal('/dashboard');
// it displays success alert
expect(findAll('.gh-alert-green').length, 'number of success alerts')
.to.equal(1);
// it opens get-started modal
expect(find('[data-test-modal="get-started"]')).to.exist;
});
it('handles validation errors in step 2', async function () {
it('handles validation errors in setup', async function () {
let postCount = 0;
await invalidateSession();
@ -168,8 +119,8 @@ describe('Acceptance: Setup', function () {
}
});
await visit('/setup/two');
await click('.gh-btn-green');
await visit('/setup');
await click('[data-test-button="setup"]');
// non-server validation
expect(find('.main-error').textContent.trim(), 'error text')
@ -181,22 +132,22 @@ describe('Acceptance: Setup', function () {
await fillIn('[data-test-blog-title-input]', 'Blog Title');
// first post - simulated validation error
await click('.gh-btn-green');
await click('[data-test-button="setup"]');
expect(find('.main-error').textContent.trim(), 'error text')
.to.equal('Server response message');
// second post - simulated server error
await click('.gh-btn-green');
await click('[data-test-button="setup"]');
expect(find('.main-error').textContent.trim(), 'error text')
.to.be.empty;
expect(findAll('.main-error').length, 'main error is not displayed')
.to.equal(0);
expect(findAll('.gh-alert-red').length, 'number of alerts')
.to.equal(1);
});
it('handles invalid origin error on step 2', async function () {
it('handles invalid origin error on setup', async function () {
// mimick the API response for an invalid origin
this.server.post('/session', function () {
return new Response(401, {}, {
@ -212,149 +163,20 @@ describe('Acceptance: Setup', function () {
await invalidateSession();
this.server.loadFixtures('roles');
await visit('/setup/two');
await visit('/setup');
await fillIn('[data-test-email-input]', 'test@example.com');
await fillIn('[data-test-name-input]', 'Test User');
await fillIn('[data-test-password-input]', 'thisissupersafe');
await fillIn('[data-test-blog-title-input]', 'Blog Title');
await click('.gh-btn-green');
await click('[data-test-button="setup"]');
// button should not be spinning
expect(findAll('.gh-btn-green .spinner').length, 'button has spinner')
expect(findAll('.gh-btn-signup .spinner').length, 'button has spinner')
.to.equal(0);
// we should show an error message
expect(find('.main-error').textContent, 'error text')
.to.have.string('Access Denied from url: unknown.com. Please use the url configured in config.js.');
});
it('handles validation errors in step 3', async function () {
let input = '[name="users"]';
let postCount = 0;
let button, formGroup;
await invalidateSession();
this.server.loadFixtures('roles');
this.server.post('/invites/', function ({invites}) {
let attrs = this.normalizedRequestAttrs();
postCount += 1;
// invalid
if (postCount === 1) {
return new Response(422, {}, {
errors: [
{
type: 'ValidationError',
message: 'Dummy validation error'
}
]
});
}
// TODO: duplicated from mirage/config/invites - extract method?
attrs.token = `${invites.all().models.length}-token`;
attrs.expires = moment.utc().add(1, 'day').valueOf();
attrs.createdAt = moment.utc().format();
attrs.createdBy = 1;
attrs.updatedAt = moment.utc().format();
attrs.updatedBy = 1;
attrs.status = 'sent';
return invites.create(attrs);
});
// complete step 2 so we can access step 3
await visit('/setup/two');
await fillIn('[data-test-email-input]', 'test@example.com');
await fillIn('[data-test-name-input]', 'Test User');
await fillIn('[data-test-password-input]', 'thisissupersafe');
await fillIn('[data-test-blog-title-input]', 'Blog Title');
await click('.gh-btn-green');
// default field/button state
formGroup = find('.gh-flow-invite .form-group');
button = find('.gh-flow-invite button[type="submit"]');
expect(formGroup, 'default field has error class')
.to.not.have.class('error');
expect(button.textContent, 'default button text')
.to.have.string('Invite some users');
expect(button, 'default button is disabled')
.to.have.class('gh-btn-minor');
// no users submitted state
await click('.gh-flow-invite button[type="submit"]');
expect(formGroup, 'no users submitted field has error class')
.to.have.class('error');
expect(button.textContent, 'no users submitted button text')
.to.have.string('No users to invite');
expect(button, 'no users submitted button is disabled')
.to.have.class('gh-btn-minor');
// single invalid email
await fillIn(input, 'invalid email');
await blur(input);
expect(formGroup, 'invalid field has error class')
.to.have.class('error');
expect(button.textContent, 'single invalid button text')
.to.have.string('1 invalid email address');
expect(button, 'invalid email button is disabled')
.to.have.class('gh-btn-minor');
// multiple invalid emails
await fillIn(input, 'invalid email\nanother invalid address');
await blur(input);
expect(button.textContent, 'multiple invalid button text')
.to.have.string('2 invalid email addresses');
// single valid email
await fillIn(input, 'invited@example.com');
await blur(input);
expect(formGroup, 'valid field has error class')
.to.not.have.class('error');
expect(button.textContent, 'single valid button text')
.to.have.string('Invite 1 user');
expect(button, 'valid email button is enabled')
.to.have.class('gh-btn-green');
// multiple valid emails
await fillIn(input, 'invited1@example.com\ninvited2@example.com');
await blur(input);
expect(button.textContent, 'multiple valid button text')
.to.have.string('Invite 2 users');
// submit invitations with simulated failure on 1 invite
await click('.gh-btn-green');
// it redirects to the home / "content" screen
expect(currentURL(), 'url after submitting invites')
.to.equal('/dashboard');
// it displays success alert
expect(findAll('.gh-alert-green').length, 'number of success alerts')
.to.equal(1);
// it displays failure alert
expect(findAll('.gh-alert-red').length, 'number of failure alerts')
.to.equal(1);
// it opens get-started modal
expect(find('[data-test-modal="get-started"]')).to.exist;
});
});
describe('?firstStart=true', function () {

View File

@ -165,7 +165,7 @@ describe('Acceptance: Signup', function () {
).to.equal('');
// submitting sends correct details and redirects to content screen
await click('.gh-btn-green');
await click('[data-test-button="signup"]');
expect(currentRouteName()).to.equal('site');
});