New setup screen for blog installation.

fixes #3072
- Change router to handle /ember/setup/
- Adjust doSignup to also handle setup
- Adjust tests and add new where necessary
- Add setup controller, setup validation, setup route
- Adjust casper emberSetup to handle new setup
This commit is contained in:
Fabian Becker 2014-06-25 14:12:48 +02:00
parent ce4d57eea2
commit 72156c7f89
19 changed files with 256 additions and 142 deletions

View File

@ -1,6 +1,6 @@
var ApplicationController = Ember.Controller.extend({
isSignedIn: Ember.computed.bool('user.isSignedIn'),
hideNav: Ember.computed.match('currentPath', /(signin|signup|forgotten|reset)/),
hideNav: Ember.computed.match('currentPath', /(signin|signup|setup|forgotten|reset)/),
actions: {
toggleMenu: function () {

View File

@ -0,0 +1,54 @@
import ajax from 'ghost/utils/ajax';
import ValidationEngine from 'ghost/mixins/validation-engine';
var SetupController = Ember.ObjectController.extend(ValidationEngine, {
blogTitle: null,
name: null,
email: null,
password: null,
submitting: false,
// ValidationEngine settings
validationType: 'setup',
actions: {
setup: function () {
var self = this;
// @TODO This should call closePassive() to only close passive notifications
self.notifications.closeAll();
this.toggleProperty('submitting');
this.validate({ format: false }).then(function () {
ajax({
url: self.get('ghostPaths').adminUrl('setup'),
type: 'POST',
headers: {
'X-CSRF-Token': self.get('csrf')
},
data: self.getProperties('blogTitle', 'name', 'email', 'password')
}).then(function (resp) {
self.toggleProperty('submitting');
if (resp && resp.userData) {
self.store.pushPayload({ users: [resp.userData]});
self.store.find('user', resp.userData.id).then(function (user) {
self.send('signedIn', user);
self.notifications.clear();
self.transitionToRoute('posts');
});
} else {
self.transitionToRoute('setup');
}
}, function (resp) {
self.toggleProperty('submitting');
self.notifications.showAPIError(resp);
});
}, function (errors) {
self.toggleProperty('submitting');
self.notifications.showErrors(errors);
});
}
}
});
export default SetupController;

View File

@ -1,83 +0,0 @@
<!doctype html>
<!--[if (IE 8)&!(IEMobile)]><html class="no-js lt-ie9" lang="en"><![endif]-->
<!--[if (gte IE 9)| IEMobile |!(IE)]><!--><html class="no-js" lang="en"><!--<![endif]-->
<head>
<meta http-equiv="Content-Type" content="text/html" charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<title>Ghost Admin</title>
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, minimal-ui" />
<meta http-equiv="cleartype" content="on" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Ghost" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon-precomposed" href="/ghost/img/touch-icon-iphone.png?v=f0332a6f54" />
<link rel="apple-touch-icon-precomposed" sizes="76x76" href="/ghost/img/touch-icon-ipad.png?v=f0332a6f54" />
<link rel="apple-touch-icon-precomposed" sizes="120x120" href="/ghost/img/small.png?v=f0332a6f54" />
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="/ghost/img/medium.png?v=f0332a6f54" />
<meta name="application-name" content="Ghost" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-square70x70logo" content="/ghost/img/small.png?v=f0332a6f54" />
<meta name="msapplication-square150x150logo" content="/ghost/img/medium.png?v=f0332a6f54" />
<meta name="msapplication-square310x310logo" content="/ghost/img/large.png?v=f0332a6f54" />
<link rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=Open+Sans:400,300,700" />
<link rel="stylesheet" href="/Users/John/Sites/Ghost-UI/dist/css/ghost-ui.css" />
</head>
<body class="ghost-setup">
<main role="main" id="main">
<aside id="notifications" class="notifications">
</aside>
<section class="setup-box js-setup-box">
<div class="vertical">
<form id="setup" class="setup-form" method="post" novalidate="novalidate">
<header>
<h1>Welcome to your new Ghost blog</h1>
<h2>Let's get a few things set up so you can get started.</h2>
</header>
<div class="form-group">
<label for="blog-title">Blog Title</label>
<input id="blog-title" type="text">
<p>What would you like to call your blog?</p>
</div>
<div class="form-group">
<label for="blog-title">Full Name</label>
<input id="blog-title" type="text">
<p>The name that you will sign your posts with</p>
</div>
<div class="form-group">
<label for="blog-title">Email Address</label>
<input id="blog-title" type="email">
<p>Used for important notifications</p>
</div>
<div class="form-group">
<label for="blog-title">Password</label>
<input id="blog-title" type="password">
<p>Must be at least 8 characters</p>
</div>
<footer>
<button class="button-add large">Ok, Let's Do This</button>
</footer>
</form>
</div>
</section>
</main>
<div id="modal-container"></div>
<div class="modal-background fade"></div>
<script src="https://newstartuptest.ghost.io/ghost/scripts/ghost.min.js"></script>
</body>
</html>

View File

@ -2,6 +2,7 @@ import { getRequestErrorMessage } from 'ghost/utils/ajax';
import ValidatorExtensions from 'ghost/utils/validator-extensions';
import PostValidator from 'ghost/validators/post';
import SetupValidator from 'ghost/validators/setup';
import SignupValidator from 'ghost/validators/signup';
import SigninValidator from 'ghost/validators/signin';
import ForgotValidator from 'ghost/validators/forgotten';
@ -12,6 +13,7 @@ ValidatorExtensions.init();
var ValidationEngine = Ember.Mixin.create({
validators: {
post: PostValidator,
setup: SetupValidator,
signup: SignupValidator,
signin: SigninValidator,
forgotten: ForgotValidator,

View File

@ -16,6 +16,7 @@ Router.reopen({
});
Router.map(function () {
this.route('setup');
this.route('signin');
this.route('signout');
this.route('signup');

View File

@ -0,0 +1,8 @@
import styleBody from 'ghost/mixins/style-body';
import loadingIndicator from 'ghost/mixins/loading-indicator';
var SetupRoute = Ember.Route.extend(styleBody, loadingIndicator, {
classNames: ['ghost-setup']
});
export default SetupRoute;

View File

@ -0,0 +1,33 @@
<section class="setup-box js-setup-box">
<div class="vertical">
<form id="setup" class="setup-form" method="post" novalidate="novalidate">
<header>
<h1>Welcome to your new Ghost blog</h1>
<h2>Let's get a few things set up so you can get started.</h2>
</header>
<div class="form-group">
<label for="blog-title">Blog Title</label>
{{input type="text" name="blog-title" autofocus="autofocus" autocorrect="off" value=blogTitle }}
<p>What would you like to call your blog?</p>
</div>
<div class="form-group">
<label for="name">Full Name</label>
{{input type="text" name="name" autofocus="autofocus" autocorrect="off" value=name }}
<p>The name that you will sign your posts with</p>
</div>
<div class="form-group">
<label for="email">Email Address</label>
{{input type="email" name="email" autofocus="autofocus" autocorrect="off" value=email }}
<p>Used for important notifications</p>
</div>
<div class="form-group">
<label for="password">Password</label>
{{input type="password" name="password" autofocus="autofocus" autocorrect="off" value=password }}
<p>Must be at least 8 characters</p>
</div>
<footer>
<button class="button-add large" {{action "setup"}} {{bind-attr disabled=submitting}}>Ok, Let's Do This</button>
</footer>
</form>
</div>
</section>

View File

@ -0,0 +1,34 @@
var SetupValidator = Ember.Object.create({
validate: function (model) {
var data = model.getProperties('blogTitle', 'name', 'email', 'password'),
validationErrors = [];
if (!validator.isLength(data.blogTitle || '', 1)) {
validationErrors.push({
message: 'Please enter a blog title.'
});
}
if (!validator.isLength(data.name || '', 1)) {
validationErrors.push({
message: 'Please enter a name.'
});
}
if (!validator.isEmail(data.email)) {
validationErrors.push({
message: 'Invalid Email.'
});
}
if (!validator.isLength(data.password || '', 1)) {
validationErrors.push({
message: 'Please enter a password.'
});
}
return validationErrors;
}
});
export default SetupValidator;

View File

@ -307,15 +307,26 @@ adminControllers = {
var name = req.body.name,
email = req.body.email,
password = req.body.password,
blogTitle = req.body.blogTitle,
users = [{
name: name,
email: email,
password: password
}];
api.users.register({users: users}).then(function (response) {
var user = response.users[0];
api.settings.edit({settings: [{key: 'email', value: email}]}, {context: {user: 1}}).then(function () {
var user = response.users[0],
settings = [];
settings.push({key: 'email', value: email});
// Handles the additional values set by the setup screen.
if (!_.isEmpty(blogTitle)) {
settings.push({key: 'title', value: blogTitle});
settings.push({key: 'description', value: 'Thoughts, stories and ideas by ' + name});
}
api.settings.edit({settings: settings}, {context: {user: 1}}).then(function () {
var message = {
to: email,
subject: 'Your New Ghost Blog',

View File

@ -168,21 +168,13 @@ function updateActiveTheme(req, res, next) {
});
}
// Redirect to signup if no users are currently created
// Redirect to signup if no user exists
// TODO Remove this when
function redirectToSignup(req, res, next) {
/*jslint unparam:true*/
api.users.doesUserExist().then(function (exists) {
if (!exists) {
// TODO remove this when ember admin becomes the default
if (req.path.match(/\/ember\//)) {
if (!req.path.match(/\/ghost\/ember\/signup\//)) {
return res.redirect(config().paths.subdir + '/ghost/ember/signup/');
} else {
return next();
}
}
// END remove this
return res.redirect(config().paths.subdir + '/ghost/signup/');
}
next();
@ -191,6 +183,20 @@ function redirectToSignup(req, res, next) {
});
}
// Redirect to setup if no user exists
function redirectToSetup(req, res, next) {
/*jslint unparam:true*/
api.users.doesUserExist().then(function (exists) {
if (!exists && !req.path.match(/\/ghost\/ember\/setup\//)) {
return res.redirect(config().paths.subdir + '/ghost/ember/setup/');
}
next();
}).otherwise(function (err) {
return next(new Error(err));
});
}
function isSSLrequired(isAdmin) {
var forceSSL = url.parse(config().url).protocol === 'https:' ? true : false,
forceAdminSSL = (isAdmin && config().forceAdminSSL);
@ -383,3 +389,4 @@ module.exports = function (server, dbHash) {
module.exports.middleware = middleware;
// Expose middleware functions in this file as well
module.exports.middleware.redirectToSignup = redirectToSignup;
module.exports.middleware.redirectToSetup = redirectToSetup;

View File

@ -33,7 +33,8 @@ var middleware = {
authenticate: function (req, res, next) {
var noAuthNeeded = [
'/ghost/signin/', '/ghost/signout/', '/ghost/signup/',
'/ghost/forgotten/', '/ghost/reset/', '/ghost/ember/'
'/ghost/forgotten/', '/ghost/reset/', '/ghost/ember/',
'/ghost/setup/'
],
path,
subPath;
@ -65,29 +66,17 @@ var middleware = {
if (!req.session.user) {
var subPath = req.path.substring(config().paths.subdir.length),
reqPath = subPath.replace(/^\/ghost\/?/gi, ''),
redirect = '',
msg;
redirect = '';
return api.notifications.browse().then(function (notifications) {
if (reqPath !== '') {
msg = {
type: 'error',
message: 'Please Sign In',
status: 'passive'
};
// let's only add the notification once
if (!_.contains(_.pluck(notifications, 'id'), 'failedauth')) {
api.notifications.add({ notifications: [msg] });
}
redirect = '?r=' + encodeURIComponent(reqPath);
}
if (reqPath !== '') {
redirect = '?r=' + encodeURIComponent(reqPath);
}
if (subPath.indexOf('/ember') > -1) {
return res.redirect(config().paths.subdir + '/ghost/ember/signin');
}
if (subPath.indexOf('/ember') > -1) {
return res.redirect(config().paths.subdir + '/ghost/ember/signin/');
}
return res.redirect(config().paths.subdir + '/ghost/signin/' + redirect);
});
return res.redirect(config().paths.subdir + '/ghost/signin/' + redirect);
}
next();
},

View File

@ -340,7 +340,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
slug = slug.charAt(slug.length - 1) === '-' ? slug.substr(0, slug.length - 1) : slug;
// Check the filtered slug doesn't match any of the reserved keywords
slug = /^(ghost|ghost\-admin|admin|wp\-admin|wp\-login|dashboard|logout|login|signin|signup|signout|register|archive|archives|category|categories|tag|tags|page|pages|post|posts|public|user|users|rss|feed|app|apps)$/g
slug = /^(ghost|ghost\-admin|admin|wp\-admin|wp\-login|dashboard|logout|login|setup|signin|signup|signout|register|archive|archives|category|categories|tag|tags|page|pages|post|posts|public|user|users|rss|feed|app|apps)$/g
.test(slug) ? slug + '-' + baseName : slug;
//if slug is empty after trimming use the model name

View File

@ -13,7 +13,7 @@ adminRoutes = function (middleware) {
// Have ember route look for hits first
// to prevent conflicts with pre-existing routes
router.get('/ghost/ember/*', middleware.redirectToSignup, admin.index);
router.get('/ghost/ember/*', middleware.redirectToSetup, admin.index);
// ### Admin routes
router.get('/logout/', function redirect(req, res) {
@ -37,6 +37,7 @@ adminRoutes = function (middleware) {
res.redirect(301, subdir + '/ghost/signup/');
});
router.post('/ghost/setup/', admin.doSignup);
router.get('/ghost/signout/', admin.signout);
router.post('/ghost/signout/', admin.doSignout);
router.get('/ghost/signin/', middleware.redirectToSignup, middleware.redirectToDashboard, admin.signin);

View File

@ -33,6 +33,12 @@ var DEBUG = false, // TOGGLE THIS TO GET MORE SCREENSHOTS
email: email,
password: password
},
newSetup = {
'blog-title': 'Test Blog',
name: 'Test User',
email: email,
password: password
},
user = {
email: email,
password: password
@ -126,11 +132,16 @@ casper.thenOpenAndWaitForPageLoad = function (screen, then, timeout) {
},
'signout': {
url: 'ghost/ember/signout/',
selector: '.button-save'
// When no user exists we get redirected to setup which has button-add
selector: '.button-save, .button-add'
},
'signup': {
url: 'ghost/ember/signup/',
selector: '.button-save'
},
'setup': {
url: 'ghost/ember/setup/',
selector: '.button-add'
}
};
@ -147,7 +158,7 @@ casper.failOnTimeout = function (test, message) {
};
// ### Fill And Save
// With Ember in place, we don't want to submit forms, rather press the green button which always has a class of
// With Ember in place, we don't want to submit forms, rather press the button which always has a class of
// 'button-save'. This method handles that smoothly.
casper.fillAndSave = function (selector, data) {
casper.then(function doFill() {
@ -156,6 +167,16 @@ casper.fillAndSave = function (selector, data) {
});
};
// ### Fill And Add
// With Ember in place, we don't want to submit forms, rather press the green button which always has a class of
// 'button-add'. This method handles that smoothly.
casper.fillAndAdd = function (selector, data) {
casper.then(function doFill() {
casper.fill(selector, data, false);
casper.thenClick('.button-add');
});
};
// ## Debugging
var jsErrors = [],
pageErrors = [],
@ -317,7 +338,7 @@ var CasperTest = (function () {
if (!_isUserRegistered) {
CasperTest.Routines.emberSignout.run();
CasperTest.Routines.emberSignup.run();
CasperTest.Routines.emberSetup.run();
_isUserRegistered = true;
}
@ -382,12 +403,12 @@ CasperTest.Routines = (function () {
}, 2000);
}
function emberSignup() {
casper.thenOpenAndWaitForPageLoad('signup', function then() {
function emberSetup() {
casper.thenOpenAndWaitForPageLoad('setup', function then() {
casper.captureScreenshot('ember_signing_up1.png');
casper.waitForOpaque('.signup-box', function then() {
this.fillAndSave('#signup', newUser);
casper.waitForOpaque('.setup-box', function then() {
this.fillAndAdd('#setup', newSetup);
});
casper.captureScreenshot('ember_signing_up2.png');
@ -531,7 +552,7 @@ CasperTest.Routines = (function () {
login: _createRunner(login),
logout: _createRunner(logout),
togglePermalinks: _createRunner(togglePermalinks),
emberSignup: _createRunner(emberSignup),
emberSetup: _createRunner(emberSetup),
emberSignin: _createRunner(emberSignin),
emberSignout: _createRunner(emberSignout),
createTestPost: _createRunner(createTestPost)

View File

@ -0,0 +1,34 @@
// # Signup Test
// Test that signup works correctly
/*global CasperTest, casper, email */
CasperTest.emberBegin('Ghost setup fails properly', 5, function suite(test) {
casper.thenOpenAndWaitForPageLoad('setup', function then() {
test.assertUrlMatch(/ghost\/ember\/setup\/$/, 'Landed on the correct URL');
});
casper.then(function setupWithShortPassword() {
casper.fillAndAdd('#setup', {email: email, password: 'test'});
});
// should now throw a short password error
casper.waitForSelector('.notification-error', function onSuccess() {
test.assert(true, 'Got error notification');
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
}, function onTimeout() {
test.assert(false, 'No error notification :(');
});
casper.then(function setupWithLongPassword() {
casper.fillAndAdd('#setup', {email: email, password: 'testing1234'});
});
// should now throw a 1 user only error
casper.waitForSelector('.notification-error', function onSuccess() {
test.assert(true, 'Got error notification');
test.assertSelectorDoesntHaveText('.notification-error', '[object Object]');
}, function onTimeout() {
test.assert(false, 'No error notification :(');
});
}, true);

View File

@ -3,7 +3,9 @@
/*global CasperTest, casper, email */
CasperTest.emberBegin('Ghost signup fails properly', 5, function suite(test) {
CasperTest.emberBegin('Ghost signup fails properly', 0, function suite(test) {
/*
casper.thenOpenAndWaitForPageLoad('signup', function then() {
test.assertUrlMatch(/ghost\/ember\/signup\/$/, 'Landed on the correct URL');
});
@ -31,4 +33,5 @@ CasperTest.emberBegin('Ghost signup fails properly', 5, function suite(test) {
}, function onTimeout() {
test.assert(false, 'No error notification :(');
});
}, true);
*/
}, true);

View File

@ -6,8 +6,8 @@ CasperTest.begin('Ensure that RSS is available', 11, function suite(test) {
CasperTest.Routines.togglePermalinks.run('off');
casper.thenOpen(url + 'rss/', function (response) {
var content = this.getPageContent(),
siteTitle = '<title><![CDATA[Ghost]]></title>',
siteDescription = '<description><![CDATA[Just a blogging platform.]]></description>',
siteTitle = '<title><![CDATA[Test Blog]]></title>',
siteDescription = '<description><![CDATA[Thoughts, stories and ideas by Test User]]></description>',
siteUrl = '<link>http://127.0.0.1:2369/</link>',
postTitle = '<![CDATA[Welcome to Ghost]]>',
postStart = '<description><![CDATA[<p>You\'re live!',

View File

@ -5,7 +5,7 @@
/*globals CasperTest, casper, __utils__, url, testPost, falseUser, email */
CasperTest.begin('Home page loads', 3, function suite(test) {
casper.start(url, function then(response) {
test.assertTitle('Ghost', 'The homepage should have a title and it should be Ghost');
test.assertTitle('Test Blog', 'The homepage should have a title and it should be "Test Blog"');
test.assertExists('.content .post', 'There is at least one post on this page');
test.assertSelectorHasText('.poweredby', 'Proudly published with Ghost');
});

View File

@ -30,21 +30,20 @@ describe('Middleware', function () {
req.path = '';
middleware.auth(req, res, null).then(function () {
assert(res.redirect.calledWithMatch('/ghost/signin/'));
done();
}).catch(done);
middleware.auth(req, res, null)
assert(res.redirect.calledWithMatch('/ghost/signin/'));
done();
});
it('should redirect to signin path with redirect paramater stripped of /ghost/', function(done) {
it('should redirect to signin path with redirect parameter stripped of /ghost/', function(done) {
var path = 'test/path/party';
req.path = '/ghost/' + path;
middleware.auth(req, res, null).then(function () {
assert(res.redirect.calledWithMatch('/ghost/signin/?r=' + encodeURIComponent(path)));
done();
}).catch(done);
middleware.auth(req, res, null)
assert(res.redirect.calledWithMatch('/ghost/signin/?r=' + encodeURIComponent(path)));
done();
});
it('should call next if session user exists', function (done) {