From 403554ae3afc3c9741ba6f1a10cc751889c6c290 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 1 Feb 2015 15:26:16 -0600 Subject: [PATCH 01/18] Users cannot block admins. Also corrected logic so user cannot block themselves --- views/shared/modals/members.jade | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index aa1cb11ddd..bafe6c62f5 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -32,9 +32,9 @@ script(type='text/ng-template', id='modals/member.html') include ../profiles/achievements .modal-footer .btn-group.pull-left(ng-if='::user') - button.btn.btn-md.btn-default(ng-show='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + button.btn.btn-md.btn-default(ng-if='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') span.glyphicon.glyphicon-plus - button.btn.btn-md.btn-default(ng-hide='profile._id == user._id || user.inbox.blocks | contains:profile._id', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + button.btn.btn-md.btn-default(ng-if='profile._id != user._id && !profile.contributor.admin && !(user.inbox.blocks | contains:profile._id)', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') span.glyphicon.glyphicon-ban-circle button.btn.btn-md.btn-default(tooltip=env.t('sendPM'), ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='right') span.glyphicon.glyphicon-envelope From d211bc7f861517a16d0d653f1be69b6e1d72ba92 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 27 Jan 2015 15:43:21 +0100 Subject: [PATCH 02/18] feat(email-notifications): implement email notifications for events --- public/js/app.js | 4 ++ src/controllers/auth.js | 6 +- src/controllers/challenges.js | 6 ++ src/controllers/groups.js | 31 +++++++++-- src/controllers/members.js | 18 ++++++ src/controllers/payments/index.js | 22 +++++++- src/controllers/user.js | 3 + src/models/user.js | 15 ++++- src/utils.js | 93 +++++++++++++++++++++---------- views/options/settings.jade | 61 ++++++++++++++++++++ views/shared/header/menu.jade | 2 + 11 files changed, 217 insertions(+), 44 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 3ee68b2a9a..742f31cfcc 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -213,6 +213,10 @@ window.habitrpg = angular.module('habitrpg', url: "/subscription", templateUrl: "partials/options.settings.subscription.html" }) + .state('options.settings.notifications', { + url: "/notifications", + templateUrl: "partials/options.settings.notifications.html" + }) var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID)); if (settings && settings.auth) { diff --git a/src/controllers/auth.js b/src/controllers/auth.js index 3a00eda7f0..a4c04a458a 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -103,7 +103,7 @@ api.registerUser = function(req, res, next) { } user.save(cb); - if(isProd) utils.txnEmail({name:username, email:email}, 'welcome'); + utils.txnEmail(user, 'welcome'); ga.event('register', 'Local').send() } ], function(err, saved) { @@ -173,9 +173,7 @@ api.loginSocial = function(req, res, next) { user = new User(user); user.save(cb); - if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) { - utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome'); - } + utils.txnEmail(user, 'welcome'); ga.event('register', network).send(); }] }, function(err, results){ diff --git a/src/controllers/challenges.js b/src/controllers/challenges.js index 71e1d82d33..b77fb00c0f 100644 --- a/src/controllers/challenges.js +++ b/src/controllers/challenges.js @@ -9,6 +9,7 @@ var Group = require('./../models/group').model; var Challenge = require('./../models/challenge').model; var logging = require('./../logging'); var csv = require('express-csv'); +var utils = require('../utils'); var api = module.exports; @@ -335,6 +336,11 @@ api.selectWinner = function(req, res, next) { winner.save(cb); }, function(saved, num, cb) { + if(saved.preferences.emailNotifications.wonChallenge !== false){ + utils.txnEmail(saved, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: chal.name} + ]); + } closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb); } ], function(err){ diff --git a/src/controllers/groups.js b/src/controllers/groups.js index 22268838ef..6fb71bdd42 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -301,13 +301,11 @@ api.flagChatMessage = function(req, res, next){ group.markModified('chat'); group.save(function(err,_saved){ if(err) return next(err); - if (isProd){ - var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL')); if(Array.isArray(addressesToSendTo)){ addressesToSendTo = addressesToSendTo.map(function(email){ - return {email: email} + return {email: email, canSend: true} }); }else{ addressesToSendTo = {email: addressesToSendTo} @@ -332,7 +330,7 @@ api.flagChatMessage = function(req, res, next){ {name: "GROUP_ID", content: group._id}, {name: "GROUP_URL", content: group._id == 'habitrpg' ? (nconf.get('BASE_URL') + '/#/options/groups/tavern') : (group.type === 'guild' ? (nconf.get('BASE_URL')+ '/#/options/groups/guilds/' + group._id) : 'party')}, ]); - } + return res.send(204); }); }); @@ -556,6 +554,26 @@ api.invite = function(req, res, next) { ], function(err, results){ if (err) return next(err); + if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ + var emailVars = [ + {name: 'INVITER', content: utils.getUserInfo(res.locals.user, ['name']).name} + ]; + + if(group.type == 'guild'){ + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/' + group._id} + ); + }else{ + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} + ) + } + + utils.txnEmail(invite, ('invited-' + group.type == 'guild' ? 'guild' : 'party'), emailVars); + } + // Have to return whole group and its members for angular to show the invited user res.json(results[2]); group = uuid = null; @@ -629,7 +647,7 @@ questStart = function(req, res, next) { var group = res.locals.group; var force = req.query.force; -// if (group.quest.active) return res.json(400,{err:'Quest already began.'}); + // if (group.quest.active) return res.json(400,{err:'Quest already began.'}); // temporarily send error email, until we know more about this issue (then remove below, uncomment above). if (group.quest.active) return next('Quest already began.'); @@ -720,8 +738,9 @@ api.questAccept = function(req, res, next) { if (m == user._id) { group.quest.members[m] = true; group.quest.leader = user._id; - } else + } else { group.quest.members[m] = undefined; + } }); // Party member accepting the invitation diff --git a/src/controllers/members.js b/src/controllers/members.js index 73db176865..f0b465547f 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -5,6 +5,8 @@ var api = module.exports; var async = require('async'); var _ = require('lodash'); var shared = require('habitrpg-shared'); +var utils = require('../utils'); +var nconf = require('nconf'); var fetchMember = function(uuid, restrict){ return function(cb){ @@ -48,9 +50,11 @@ api.sendMessage = function(user, member, data){ } api.sendPrivateMessage = function(req, res, next){ + var fetchedMember; async.waterfall([ fetchMember(req.params.uuid), function(member, cb) { + fetchedMember = member; if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me || ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them || member.inbox.optOut) { // or if they've opted out of messaging @@ -64,6 +68,14 @@ api.sendPrivateMessage = function(req, res, next){ } ], function(err){ if (err) return sendErr(err, res, next); + + if(fetchedMember.preferences.emailNotifications.newPM !== false){ + utils.txnEmail(fetchedMember, 'new-pm', [ + {name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name}, + {name: 'PMS_INBOX_URL', content: nconf.get('BASE_URL') + '/#/options/groups/inbox'} + ]); + } + res.send(200); }) } @@ -84,6 +96,12 @@ api.sendGift = function(req, res, next){ member.balance += amt; user.balance -= amt; api.sendMessage(user, member, req.body); + if(member.preferences.emailNotifications.giftedGems !== false){ + utils.txnEmail(member, 'gifted-gems', [ + {name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name}, + {name: 'X_GEMS_GIFTED', content: amt} + ]); + } return async.parallel([ function (cb2) { member.save(cb2) }, function (cb2) { user.save(cb2) } diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index c2f83959de..5b9a5a5269 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -73,7 +73,15 @@ exports.createSubscription = function(data, cb) { utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send(); } data.user.purchased.txnCount++; - if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift){ + members.sendMessage(data.user, data.gift.member, data.gift); + if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ + utils.txnEmail(member, 'gifted-subscription', [ + {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months} + ]); + } + } async.parallel([ function(cb2){data.user.save(cb2)}, function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} @@ -96,7 +104,7 @@ exports.cancelSubscription = function(data, cb) { p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated data.user.save(cb); - if(isProduction) utils.txnEmail(data.user, 'cancel-subscription'); + utils.txnEmail(data.user, 'cancel-subscription'); utils.ga.event('unsubscribe', data.paymentMethod).send(); } @@ -110,7 +118,15 @@ exports.buyGems = function(data, cb) { //TODO ga.transaction to reflect whether this is gift or self-purchase utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); } - if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift){ + members.sendMessage(data.user, data.gift.member, data.gift); + if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ + utils.txnEmail(member, 'gifted-gems', [ + {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, + {name: 'X_GEMS_GIFTED', content: amt} + ]); + } + } async.parallel([ function(cb2){data.user.save(cb2)}, function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} diff --git a/src/controllers/user.js b/src/controllers/user.js index 43cc9ff8a2..8557dd22a8 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -445,6 +445,9 @@ api.inviteFriends = function(req, res, next) { {name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name}, {name: 'INVITEE', content: invite.name} ]; + + invite.canSend = true; + // TODO implement "users can only be invited once" utils.txnEmail(invite, 'invite-friend', variables); } diff --git a/src/models/user.js b/src/models/user.js index b61c7ec531..62862bfd75 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -297,7 +297,20 @@ var UserSchema = new Schema({ advancedCollapsed: {type: Boolean, 'default': false}, toolbarCollapsed: {type:Boolean, 'default':false}, background: String, - webhooks: {type: Schema.Types.Mixed, 'default': {}} + webhooks: {type: Schema.Types.Mixed, 'default': {}}, + // For this fields make sure to use strict comparison when searching for falsey values (=== false) + // As users who didn't login after these were introduced may have them undefined/null + emailNotifications: { + unsubscribeFromAll: {type: Boolean, 'default': false}, + newPM: {type: Boolean, 'default': true}, + wonChallenge: {type: Boolean, 'default': true}, + giftedGems: {type: Boolean, 'default': true}, + giftedSubscription: {type: Boolean, 'default': true}, + invitedParty: {type: Boolean, 'default': true}, + invitedGuild: {type: Boolean, 'default': true}, + //remindersToLogin: {type: Boolean, 'default': true}, + importantAnnouncements: {type: Boolean, 'default': true} + } }, profile: { blurb: String, diff --git a/src/utils.js b/src/utils.js index 613cde9bdd..55cab68ede 100644 --- a/src/utils.js +++ b/src/utils.js @@ -4,6 +4,9 @@ var crypto = require('crypto'); var path = require("path"); var request = require('request'); +// Set when utils.setupConfig is run +var isProd, baseUrl; + module.exports.ga = undefined; // set Google Analytics on nconf init module.exports.sendEmail = function(mailData) { @@ -22,48 +25,75 @@ module.exports.sendEmail = function(mailData) { }); } -function getMailingInfo(user) { - var email, name; - if(user.auth.local && user.auth.local.email){ - email = user.auth.local.email; - name = user.profile.name || user.auth.local.username; - }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ - email = user.auth.facebook.emails[0].value; - name = user.auth.facebook.displayName || user.auth.facebook.username; +function getUserInfo(user, fields) { + var info = {}; + + if(fields.indexOf('name') != -1){ + if(user.auth.local){ + info.name = user.profile.name || user.auth.local.username; + }else if(user.auth.facebook){ + info.name = user.auth.facebook.displayName || user.auth.facebook.username; + } } - return {email: email, name: name}; + + if(fields.indexOf('email') != -1){ + if(user.auth.local){ + info.email = user.auth.local.email; + }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ + info.email = user.auth.facebook.emails[0].value; + } + } + + if(fields.indexOf('canSend') != -1){ + info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; + } + + return info; } +module.exports.getUserInfo = getUserInfo; + module.exports.txnEmail = function(mailingInfoArray, emailType, variables){ - var variables = [{name: 'BASE_URL', content: nconf.get('BASE_URL')}].concat(variables || []); var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; + var variables = [ + {name: 'BASE_URL', content: baseUrl}, + {name: 'EMAIL_SETTINGS_URL', content: baseUrl + '/#/options/settings/notifications'} + ].concat(variables || []); + // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed mailingInfoArray = mailingInfoArray.map(function(mailingInfo){ - return mailingInfo._id ? getMailingInfo(mailingInfo) : mailingInfo; + return mailingInfo._id ? getUserInfo(mailingInfo, ['email', 'name', 'canSend']) : mailingInfo; }).filter(function(mailingInfo){ - return mailingInfo.email ? true : false; + return (mailingInfo.email && mailingInfo.canSend); }); - request({ - url: nconf.get('EMAIL_SERVER:url') + '/job', - method: 'POST', - auth: { - user: nconf.get('EMAIL_SERVER:authUser'), - pass: nconf.get('EMAIL_SERVER:authPassword') - }, - json: { - type: 'email', - data: { - emailType: emailType, - to: mailingInfoArray, - variables: variables + // When only one recipient send his info as variables + if(mailingInfoArray.length === 1 && mailingInfoArray[0].name){ + variables.push({name: 'RECIPIENT_NAME', content: mailingInfoArray[0].name}); + } + + if(isProd && mailingInfoArray.length > 0){ + request({ + url: nconf.get('EMAIL_SERVER:url') + '/job', + method: 'POST', + auth: { + user: nconf.get('EMAIL_SERVER:authUser'), + pass: nconf.get('EMAIL_SERVER:authPassword') }, - options: { - attemps: 5, - backoff: {delay: 10*60*1000, type: 'fixed'} + json: { + type: 'email', + data: { + emailType: emailType, + to: mailingInfoArray, + variables: variables + }, + options: { + attemps: 5, + backoff: {delay: 10*60*1000, type: 'fixed'} + } } - } - }); + }); + } } // Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/ @@ -93,6 +123,9 @@ module.exports.setupConfig = function(){ if (nconf.get('NODE_ENV') === 'production') require('newrelic'); + isProd = nconf.get('NODE_ENV') === 'production'; + baseUrl = nconf.get('BASE_URL'); + module.exports.ga = require('universal-analytics')(nconf.get('GA_ID')); }; diff --git a/views/options/settings.jade b/views/options/settings.jade index b6208ffc88..53c756ebcf 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -14,6 +14,8 @@ script(id='partials/options.settings.html', type="text/ng-template") =env.t('coupon') li(ng-class="{ active: $state.includes('options.settings.subscription') }") a(ui-sref='options.settings.subscription')=env.t('subscription') + li(ng-class="{ active: $state.includes('options.settings.notifications') }") + a(ui-sref='options.settings.notifications')=env.t('notifications') .tab-content .tab-pane.active @@ -84,6 +86,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') |  =env.t('subWarning3') + .personal-options.col-md-6 .panel.panel-default .panel-heading span Registration @@ -242,6 +245,64 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template') input.focusable(type='checkbox', checked) label +script(id='partials/options.settings.notifications.html', type="text/ng-template") + .container-fluid + .row + .personal-options.col-md-6 + .panel.panel-default + .panel-heading + =env.t('emailNotifications') + .panel-body + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.newPM', ng-change='set({"preferences.emailNotifications.newPM": user.preferences.emailNotifications.newPM ? true: false})') + span=env.t('newPM') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.wonChallenge', ng-change='set({"preferences.emailNotifications.wonChallenge": user.preferences.emailNotifications.wonChallenge ? true: false})') + span=env.t('wonChallenge') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedGems', ng-change='set({"preferences.emailNotifications.giftedGems": user.preferences.emailNotifications.giftedGems ? true: false})') + span=env.t('giftedGems') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedSubscription', ng-change='set({"preferences.emailNotifications.giftedSubscription": user.preferences.emailNotifications.giftedSubscription ? true: false})') + span=env.t('giftedSubscription') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedParty', ng-change='set({"preferences.emailNotifications.invitedParty": user.preferences.emailNotifications.invitedParty ? true: false})') + span=env.t('invitedParty') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedGuild', ng-change='set({"preferences.emailNotifications.invitedGuild": user.preferences.emailNotifications.invitedGuild ? true: false})') + span=env.t('invitedGuild') + + //.checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.remindersToLogin', ng-change='set({"preferences.emailNotifications.remindersToLogin": user.preferences.emailNotifications.remindersToLogin ? true: false})') + span=env.t('remindersToLogin') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.importantAnnouncements', ng-change='set({"preferences.emailNotifications.importantAnnouncements": user.preferences.emailNotifications.importantAnnouncements ? true: false})') + span=env.t('importantAnnouncements') + + hr + + .checkbox + label + input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})') + span=env.t('unsubscribeAllEmails') + + small=env.t('unsubscribeAllEmailsText') + script(id='partials/options.settings.subscription.html',type='text/ng-template') //-h2=env.t('individualSub') .container-fluid(ng-init='_subscription={key:"basic_earned"}') diff --git a/views/shared/header/menu.jade b/views/shared/header/menu.jade index 549af9c4e5..a08920b3af 100644 --- a/views/shared/header/menu.jade +++ b/views/shared/header/menu.jade @@ -242,6 +242,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') a(ui-sref='options.settings.coupon') Coupon li a(ui-sref='options.settings.subscription')=env.t('subscription') + li + a(ui-sref='options.settings.notifications')=env.t('notifications') ul.toolbar-submenu(ng-click='expandMenu(null)') li a(href="http://habitrpg.wikia.com/wiki/FAQ", target='_blank')=env.t('FAQ') From b0a2f1fcaad45101c966cc6a42cf25fa5f32d601 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 2 Feb 2015 16:50:14 +0100 Subject: [PATCH 03/18] check for subscription preferences when sending party invitations --- src/controllers/user.js | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index 8557dd22a8..5f70d13780 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -437,19 +437,34 @@ api.cast = function(req, res, next) { api.inviteFriends = function(req, res, next) { Group.findOne({type:'party', members:{'$in': [res.locals.user._id]}}).select('_id name').exec(function(err,party){ if (err) return next(err); - var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name})); + _.each(req.body.emails, function(invite){ if (invite.email) { - var variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name}, - {name: 'INVITEE', content: invite.name} - ]; - invite.canSend = true; + User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email} + ]}).select({_id: true, 'preferences.emailNotifications': true}) + .exec(function(err, userToContact){ + if(err) return next(err); + + var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name})); + + var variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || utils.getUserInfo(res.locals.user, ['name']).name} + ]; + + invite.canSend = true; + + // We check for unsubscribeFromAll here because don't pass through utils.getUserInfo + if(userToContact.preferences.emailNotifications.invitedParty !== false && + userToContact.preferences.emailNotifications.unsubscribeFromAll !== true){ + // TODO implement "users can only be invited once" + utils.txnEmail(invite, 'invite-friend', variables); + } + }); - // TODO implement "users can only be invited once" - utils.txnEmail(invite, 'invite-friend', variables); } }); res.send(200); From 9018a45d9137424156bf8d5b9da488f8a46b7719 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 2 Feb 2015 17:21:02 +0100 Subject: [PATCH 04/18] fix emailType --- src/controllers/groups.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/groups.js b/src/controllers/groups.js index 6fb71bdd42..a0f1a57493 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -571,7 +571,7 @@ api.invite = function(req, res, next) { ) } - utils.txnEmail(invite, ('invited-' + group.type == 'guild' ? 'guild' : 'party'), emailVars); + utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); } // Have to return whole group and its members for angular to show the invited user From 0c88d1fc1174e42510fbb0646da1c004ae4cfd21 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 2 Feb 2015 17:27:58 +0100 Subject: [PATCH 05/18] fix gems amount --- src/controllers/members.js | 2 +- src/controllers/payments/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/members.js b/src/controllers/members.js index f0b465547f..10b0a77481 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -99,7 +99,7 @@ api.sendGift = function(req, res, next){ if(member.preferences.emailNotifications.giftedGems !== false){ utils.txnEmail(member, 'gifted-gems', [ {name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name}, - {name: 'X_GEMS_GIFTED', content: amt} + {name: 'X_GEMS_GIFTED', content: req.body.gems.amount} ]); } return async.parallel([ diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index 5b9a5a5269..af39684017 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -123,7 +123,7 @@ exports.buyGems = function(data, cb) { if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ utils.txnEmail(member, 'gifted-gems', [ {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, - {name: 'X_GEMS_GIFTED', content: amt} + {name: 'X_GEMS_GIFTED', content: data.gift.gems.amount || 20} ]); } } From 44f73f26cd55c1c12fa913fb63bd84c568ffa5d1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 2 Feb 2015 17:51:51 +0100 Subject: [PATCH 06/18] fix guilds email link --- src/controllers/groups.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/groups.js b/src/controllers/groups.js index a0f1a57493..1754175228 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -562,7 +562,7 @@ api.invite = function(req, res, next) { if(group.type == 'guild'){ emailVars.push( {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/' + group._id} + {name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/public'} ); }else{ emailVars.push( From 6945fb57676819b4a67a17902827a5419093b062 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 2 Feb 2015 18:41:42 +0100 Subject: [PATCH 07/18] allow emails to non registered users --- src/controllers/user.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index 5f70d13780..ffa22c3e79 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -458,8 +458,8 @@ api.inviteFriends = function(req, res, next) { invite.canSend = true; // We check for unsubscribeFromAll here because don't pass through utils.getUserInfo - if(userToContact.preferences.emailNotifications.invitedParty !== false && - userToContact.preferences.emailNotifications.unsubscribeFromAll !== true){ + if(!userToContact || (userToContact.preferences.emailNotifications.invitedParty !== false && + userToContact.preferences.emailNotifications.unsubscribeFromAll !== true)){ // TODO implement "users can only be invited once" utils.txnEmail(invite, 'invite-friend', variables); } From 48246d6a7239687f3b44ad82c56aed8f6e625f4e Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Mon, 2 Feb 2015 18:55:06 -0600 Subject: [PATCH 08/18] feat(seasonal): Normal view --- views/options/inventory/inventory.jade | 8 ++++---- views/options/profile.jade | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 5fc6dc9de5..9ed514c821 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -23,14 +23,14 @@ script(type='text/ng-template', id='partials/options.inventory.seasonalshop.html .container-fluid .stable.row .col-md-2 - .seasonalshop_winter2015 + .seasonalshop_closed .col-md-10 .popover.static-popover.fade.right.in .arrow - h3.popover-title!=env.t('seasonalShopTitle', {linkStart:"", linkEnd: ""}) + h3.popover-title!=env.t('seasonalShopClosedTitle', {linkStart:"", linkEnd: ""}) .popover-content - p!=env.t('seasonalShopText') - br + p!=env.t('seasonalShopClosedText') + // br .well(ng-if='User.user.achievements.rebirths > 0')=env.t('seasonalShopRebirth') li.customize-menu.inventory-gear menu.pets-menu(label='{{::label}}', ng-repeat='(set,label) in ::{candycane:env.t("candycaneSet"), ski:env.t("skiSet"), snowflake:env.t("snowflakeSet"), yeti:env.t("yetiSet")}') diff --git a/views/options/profile.jade b/views/options/profile.jade index 300d207e0c..c2250f06b8 100644 --- a/views/options/profile.jade +++ b/views/options/profile.jade @@ -63,7 +63,7 @@ mixin customizeProfile(mobile) button(type='button', ng-if='user.purchased.hair.color.#{color}', class='customize-option hair hair_bangs_1_#{color}', ng-click='unlock("hair.color.#{color}")') +buyPref('hair.color', ['rainbow','yellow','green','purple','blue','TRUred'], 'rainbowColors') +buyPref('hair.color', ['candycorn','ghostwhite','halloween','midnight','pumpkin','zombie'], 'hauntedColors', 'disabled') - +buyPref('hair.color', ['aurora','festive','hollygreen','peppermint','snowy','winterstar'], 'winteryColors') + +buyPref('hair.color', ['aurora','festive','hollygreen','peppermint','snowy','winterstar'], 'winteryColors', 'disabled') li.customize-menu menu(label=env.t('bodyHair')) From 1b0cd6b07314dec206a224fa877ed355b5815dd6 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Mon, 2 Feb 2015 19:06:52 -0600 Subject: [PATCH 09/18] chore(news): Feb 2 Bailey --- views/shared/new-stuff.jade | 48 ++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/views/shared/new-stuff.jade b/views/shared/new-stuff.jade index b79c09166b..75b3e4a908 100644 --- a/views/shared/new-stuff.jade +++ b/views/shared/new-stuff.jade @@ -1,31 +1,47 @@ -h5 1/30/2015 - HABITRPG BIRTHDAY BASH AND PARTY ROBES! PLUS, LAST CHANCE FOR STARRY KNIGHT ITEM SET, AND WINTER WONDERLAND OUTFITS AND HAIR COLORS! +h5 2/2/2015 - FEBRUARY MYSTERY BOX, NEW QUEST DESCRIPTIONS, AND END OF SPREAD THE WORD CHALLENGE hr tr td - .npc_alex.pull-left - h5 HabitRPG Birthday Bash - p January 31st is HabitRPG's Birthday! All of the NPCs are celebrating, and we've awarded you a bunch of cake for your pets and mounts! + h5 February Mystery Box + .inventory_present.pull-right + p Ooh... What could it be? All Habiticans who are subscribed during the month of February will receive the February Mystery Item Set! It will be revealed on the 24th, so keep your eyes peeled. Thanks for supporting the site <3 + p.small.muted by Lemoness tr td - h5 Party Robes - .shop_armor_special_birthday.pull-right - .shop_armor_special_birthday2015.pull-right - p Until February 1st only, there are Party Robes available for free in the Rewards store! If this is your first Birthday bash with us, you can find some Absurd Party Robes; if you already got some last year, then you will find the Silly Party Robes. + h5 New Quest Descriptions + p We've updated quest descriptions so that when you hover over them, you can now see the Boss or Collection stats and the Rewards that you will gain when you complete the quest! + p.small.muted by Blade tr td - .promo_mystery_201501.pull-left - h5 Last Chance for Starry Knight Item Set - p Reminder: this is the final day to subscribe and receive the Starry Knight Item Set! If you want the Starry Helm or the Starry Armor, now's the time! Thanks so much for your support <3 - tr - td - h5 Last Chance for Winter Wonderland Outfits + Hair Colors - .promo_winterclasses2015.pull-right - p Tomorrow everything will be back to normal in Habitica, so if you still have any remaining Winter Wonderland Items that you want to buy, you'd better do it now! The Seasonal Edition items and Hair Colors won't be back until next December, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot! + h5 Spread the Word Challenge Has Ended + p The Spread the Word Challenge has ended! Thank you to all the participants. It will be some time before the winners are announced because we have to go over all the entries ourselves. Thanks for your patience! hr a(href='/static/old-news', target='_blank') Read older news mixin oldNews + h5 1/30/2015 + tr + td + .npc_alex.pull-left + h5 HabitRPG Birthday Bash + p January 31st is HabitRPG's Birthday! All of the NPCs are celebrating, and we've awarded you a bunch of cake for your pets and mounts! + tr + td + h5 Party Robes + .shop_armor_special_birthday.pull-right + .shop_armor_special_birthday2015.pull-right + p Until February 1st only, there are Party Robes available for free in the Rewards store! If this is your first Birthday bash with us, you can find some Absurd Party Robes; if you already got some last year, then you will find the Silly Party Robes. + tr + td + .promo_mystery_201501.pull-left + h5 Last Chance for Starry Knight Item Set + p Reminder: this is the final day to subscribe and receive the Starry Knight Item Set! If you want the Starry Helm or the Starry Armor, now's the time! Thanks so much for your support <3 + tr + td + h5 Last Chance for Winter Wonderland Outfits + Hair Colors + .promo_winterclasses2015.pull-right + p Tomorrow everything will be back to normal in Habitica, so if you still have any remaining Winter Wonderland Items that you want to buy, you'd better do it now! The Seasonal Edition items and Hair Colors won't be back until next December, and if the Limited Edition items return they will have increased prices or changed art, so strike while the iron is hot! h5 1/26/2015 tr td From f60cef4e167b75ed5443c8705fb3761be445e1f2 Mon Sep 17 00:00:00 2001 From: Alice Harris Date: Tue, 3 Feb 2015 17:23:17 +1000 Subject: [PATCH 10/18] updated for second phase of removal (commented-out code was first phase / notes) --- ...150131_birthday_goodies_fix_remove_robe.js | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/migrations/20150131_birthday_goodies_fix_remove_robe.js b/migrations/20150131_birthday_goodies_fix_remove_robe.js index 997cd8bdc1..879a4dbc98 100644 --- a/migrations/20150131_birthday_goodies_fix_remove_robe.js +++ b/migrations/20150131_birthday_goodies_fix_remove_robe.js @@ -1,9 +1,9 @@ -var migrationName = '20150131_birthday_goodies_fix_remove_robe.js'; +var migrationName = '20150131_birthday_goodies_fix__one_birthday__1'; var authorName = 'Alys'; // in case script author needs to know when their ... var authorUuid = 'd904bd62-da08-416b-a816-ba797c9ee265'; //... own data is done -/** - * remove new birthday robes from people who don't have original birthday achievement +/* + * remove new birthday robes and second achievement from people who shouldn't have them */ var dbserver = 'localhost:27017' // CHANGE THIS FOR PRODUCTION DATABASE @@ -13,12 +13,22 @@ var _ = require('lodash'); var dbUsers = mongo.db(dbserver + '/habitrpg?auto_reconnect').collection('users'); + // 'auth.timestamps.created':{$gt:new Date('2014-02-01')}, var query = { - 'achievements.habitBirthday':{$exists:false} - }; + 'achievements.habitBirthdays':1, + 'auth.timestamps.loggedin':{$gt:new Date('2014-12-20')} + }; + + // '_id': 'c03e41bd-501f-438c-9553-a7afdf52a08c', + // 'achievements.habitBirthday':{$exists:false}, + // 'items.gear.owned.armor_special_birthday2015':1 var fields = { - 'items.gear.owned.armor_special_birthday2015':1 + // 'auth.timestamps.created':1, + // 'achievements.habitBirthday':1, + // 'achievements.habitBirthdays':1, + 'items.gear.owned.armor_special_birthday2015':1, + // 'items.gear.owned.armor_special':1 }; console.warn('Updating users...'); @@ -33,9 +43,11 @@ dbUsers.findEach(query, fields, {batchSize:250}, function(err, user) { count++; var unset = {'items.gear.owned.armor_special_birthday2015': 1}; - var set = {'migration': migrationName}; + // var set = {'migration':migrationName, 'achievements.habitBirthdays':1 }; // var inc = {'xyz':1, _v:1}; - dbUsers.update({_id:user._id}, {$unset:unset, $set:set}); // , $inc:inc}); + dbUsers.update({_id:user._id}, {$unset:unset}); // , $inc:inc}); + // dbUsers.update({_id:user._id}, {$unset:unset, $set:set}); + // console.warn(user.auth.timestamps.created); if (count%progressCount == 0) console.warn(count + ' ' + user._id); if (user._id == authorUuid) console.warn(authorName + ' processed'); From 162ea2fc05bff14e5850aa76dd8ca2bfdce2e6aa Mon Sep 17 00:00:00 2001 From: Alice Harris Date: Tue, 3 Feb 2015 17:43:07 +1000 Subject: [PATCH 11/18] convert date strings or empty string to date objects for auth.timestamps.created --- ...ert_creation_date_from_string_to_object.js | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 migrations/20150201_convert_creation_date_from_string_to_object.js diff --git a/migrations/20150201_convert_creation_date_from_string_to_object.js b/migrations/20150201_convert_creation_date_from_string_to_object.js new file mode 100644 index 0000000000..bc3a98bb02 --- /dev/null +++ b/migrations/20150201_convert_creation_date_from_string_to_object.js @@ -0,0 +1,106 @@ +var migrationName = '20150201_convert_creation_date_from_string_to_object__no_date_recent_signup'; +//// var migrationName = '20150201_convert_creation_date_from_string_to_object'; + +var authorName = 'Alys'; // in case script author needs to know when their ... +var authorUuid = 'd904bd62-da08-416b-a816-ba797c9ee265'; //... own data is done + +/* + * For users that have no value for auth.timestamps.created, assign them + * a recent value. + * + * NOTE: + * Before this script was used as described above, it was first used to + * find all users that have a auth.timestamps.created field that is a string + * rather than a date object and set it to be a date object. The code used + * for this has been commented out with four slashes: //// + * + * https://github.com/HabitRPG/habitrpg/issues/4601#issuecomment-72339846 + */ + +var dbserver = 'localhost:27017' // CHANGE THIS FOR PRODUCTION DATABASE + +var mongo = require('mongoskin'); +var _ = require('lodash'); +var moment = require('moment'); + +var dbUsers = mongo.db(dbserver + '/habitrpg?auto_reconnect').collection('users'); + +var uuidArrayRecent=[ // recent users with no creation dates +'1a0d4b75-73ed-4937-974d-d504d6398884', +'1c7ebe27-1250-4f95-ba10-965580adbfd7', +'5f972121-4a6d-411c-95e9-7093d3e89b66', +'ae85818a-e336-4ccd-945e-c15cef975102', +'ba273976-d9fc-466c-975f-38559d34a824', +]; + +var query = { + '_id':{$in: uuidArrayRecent} + //// 'auth':{$exists:true}, + //// 'auth.timestamps':{$exists:true}, + //// 'auth.timestamps.created':{$not: {$lt:new Date('2018-01-01')}} + }; + +var fields = { + '_id':1, + 'auth.timestamps.created':1 + }; + // 'achievements.habitBirthdays':1 + +console.warn('Updating users...'); +var progressCount = 1000; +var count = 0; +dbUsers.findEach(query, fields, {batchSize:250}, function(err, user) { + if (err) { return exiting(1, 'ERROR! ' + err); } + if (!user) { + console.warn('All appropriate users found and modified.'); + return displayData(); + } + count++; + + //// var oldDate = user.auth.timestamps.created; + //// var newDate = moment(oldDate).toDate(); + var oldDate = 'none'; + var newDate = moment('2015-01-11').toDate(); + console.warn(user._id + ' == ' + oldDate + ' == ' + newDate); + + //// var set = { 'migration': migrationName, + //// 'auth.timestamps.created': newDate, + //// 'achievements.habitBirthdays': 2, + //// 'items.gear.owned.head_special_nye':true, + //// 'items.gear.owned.head_special_nye2014':true, + //// 'items.gear.owned.armor_special_birthday':true, + //// 'items.gear.owned.armor_special_birthday2015':true, + //// }; + + var set = { 'migration': migrationName, + 'auth.timestamps.created': newDate, + 'achievements.habitBirthdays': 1, + 'items.gear.owned.armor_special_birthday':true, + }; + + // var unset = {'items.gear.owned.armor_special_birthday2015': 1}; + // var inc = {'xyz':1, _v:1}; + dbUsers.update({_id:user._id}, {$set:set}); + // dbUsers.update({_id:user._id}, {$unset:unset, $set:set, $inc:inc}); + + if (count%progressCount == 0) console.warn(count + ' ' + user._id); + if (user._id == authorUuid) console.warn(authorName + ' processed'); + if (user._id == '9' ) console.warn('lefnire' + ' processed'); +}); + + +function displayData() { + console.warn('\n' + count + ' users processed\n'); + return exiting(0); +} + + +function exiting(code, msg) { + code = code || 0; // 0 = success + if (code && !msg) { msg = 'ERROR!'; } + if (msg) { + if (code) { console.error(msg); } + else { console.log( msg); } + } + process.exit(code); +} From 1a8a688c618897ea028ec173bf0dedbc5995a48c Mon Sep 17 00:00:00 2001 From: Alice Harris Date: Tue, 3 Feb 2015 18:06:41 +1000 Subject: [PATCH 12/18] add link variables to seasonalShopClosedText --- views/options/inventory/inventory.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 9ed514c821..c8d066ba93 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -29,7 +29,7 @@ script(type='text/ng-template', id='partials/options.inventory.seasonalshop.html .arrow h3.popover-title!=env.t('seasonalShopClosedTitle', {linkStart:"", linkEnd: ""}) .popover-content - p!=env.t('seasonalShopClosedText') + p!=env.t('seasonalShopClosedText', {linkStart:"", linkEnd: ""}) // br .well(ng-if='User.user.achievements.rebirths > 0')=env.t('seasonalShopRebirth') li.customize-menu.inventory-gear From 1e642fa4a87c130e71184bf8a573471d8b1d1c53 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 3 Feb 2015 10:29:36 -0600 Subject: [PATCH 13/18] Closed #4616. Added modal attribute habitrpg-tasks directive to determine whether modals should be appended to body or to the task --- public/js/directives/directives.js | 1 + views/options/social/challenges.jade | 2 +- views/shared/tasks/task.jade | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/public/js/directives/directives.js b/public/js/directives/directives.js index e4793d7e0c..d3de6d20a6 100644 --- a/public/js/directives/directives.js +++ b/public/js/directives/directives.js @@ -61,6 +61,7 @@ habitrpg link: function(scope, element, attrs) { // $scope.obj needs to come from controllers, so we can pass by ref scope.main = attrs.main; + scope.modal = attrs.modal; var dailiesView; if(User.user.preferences.dailyDueDefaultView) { dailiesView = "remaining"; diff --git a/views/options/social/challenges.jade b/views/options/social/challenges.jade index 1224cfae41..1f93754220 100644 --- a/views/options/social/challenges.jade +++ b/views/options/social/challenges.jade @@ -16,7 +16,7 @@ script(type='text/ng-template', id='partials/options.social.challenges.detail.me button.close(type='button', ng-click='$state.go("^")', aria-hidden='true') × h3 {{obj.profile.name}} .modal-body - habitrpg-tasks(main=false) + habitrpg-tasks(main=false, modal='true') .modal-footer a.btn.btn-default(ng-click='$state.go("^")')=env.t('close') diff --git a/views/shared/tasks/task.jade b/views/shared/tasks/task.jade index b50c04460c..9350697474 100644 --- a/views/shared/tasks/task.jade +++ b/views/shared/tasks/task.jade @@ -1,4 +1,4 @@ -li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s"]', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-class='{"cast-target":spell && (list.type != "reward")}', popover-trigger='mouseenter', data-popover-html="{{task.notes | markdown}}", popover-placement="top", popover-append-to-body='true', ng-show='shouldShow(task, list, user.preferences)') +li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s"]', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-class='{"cast-target":spell && (list.type != "reward")}', popover-trigger='mouseenter', data-popover-html="{{task.notes | markdown}}", popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}', ng-show='shouldShow(task, list, user.preferences)') // right-hand side control buttons .task-meta-controls From fd900a5edc23d0521426b506d7502815fe1095da Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 3 Feb 2015 21:11:02 +0100 Subject: [PATCH 14/18] disabling loggly here and via configuration --- src/controllers/user.js | 78 +++++++++++++++-------------------------- src/logging.js | 1 + src/middleware.js | 4 +-- 3 files changed, 31 insertions(+), 52 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index ffa22c3e79..2d7b1982f4 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -261,58 +261,36 @@ api.update = function(req, res, next) { }; api.cron = function(req, res, next) { - try{ - var user = res.locals.user, - progress = user.fns.cron(), - ranCron = user.isModified(), - quest = shared.content.quests[user.party.quest.key]; + var user = res.locals.user, + progress = user.fns.cron(), + ranCron = user.isModified(), + quest = shared.content.quests[user.party.quest.key]; - if (ranCron) res.locals.wasModified = true; - if (!ranCron) return next(null,user); - Group.tavernBoss(user,progress); - if (!quest) return user.save(next); - - // FOR DEBUGGING, PLEASE IGNORE - var opStatus = null; - - // If user is on a quest, roll for boss & player, or handle collections - // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? - async.waterfall([ - function(cb){ - opStatus = 'saveUser'; - user.save(cb); // make sure to save the cron effects - }, - function(saved, count, cb){ - opStatus = 'runQuest'; - var type = quest.boss ? 'boss' : 'collect'; - Group[type+'Quest'](user,progress,cb); - }, - function(){ - var cb = arguments[arguments.length-1]; - // User has been updated in boss-grapple, reload - User.findById(user._id, cb); - } - ], function(err, saved) { - if(err) logging.loggly({ - error: "Cron caught", - stack: (err.stack || err.message || err), - body: req.body, headers: req.header, - auth: req.headers['x-api-user'], - originalUrl: req.originalUrl, - opStatus: opStatus - }); - res.locals.user = saved; - next(err,saved); - user = progress = quest = null; - }); - }catch(e){ - logging.loggly({ - error: "Cron uncaught", - stack: e.stack || e - }); - throw e; - } + if (ranCron) res.locals.wasModified = true; + if (!ranCron) return next(null,user); + Group.tavernBoss(user,progress); + if (!quest) return user.save(next); + // If user is on a quest, roll for boss & player, or handle collections + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + async.waterfall([ + function(cb){ + user.save(cb); // make sure to save the cron effects + }, + function(saved, count, cb){ + var type = quest.boss ? 'boss' : 'collect'; + Group[type+'Quest'](user,progress,cb); + }, + function(){ + var cb = arguments[arguments.length-1]; + // User has been updated in boss-grapple, reload + User.findById(user._id, cb); + } + ], function(err, saved) { + res.locals.user = saved; + next(err,saved); + user = progress = quest = null; + }); }; // api.reroll // Shared.ops diff --git a/src/logging.js b/src/logging.js index 1f4a1d771e..16b7b497f2 100644 --- a/src/logging.js +++ b/src/logging.js @@ -5,6 +5,7 @@ require('winston-newrelic'); var logger, loggly; +// Currently disabled if (nconf.get('LOGGLY:enabled')){ loggly = require('loggly').createClient({ token: nconf.get('LOGGLY:token'), diff --git a/src/middleware.js b/src/middleware.js index 189275443b..0c6c70d5d8 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -75,13 +75,13 @@ module.exports.errorHandler = function(err, req, res, next) { "\n\nbody: " + JSON.stringify(req.body) + (res.locals.ops ? "\n\ncompleted ops: " + JSON.stringify(res.locals.ops) : ""); logging.error(stack); - logging.loggly({ + /*logging.loggly({ error: "Uncaught error", stack: (err.stack || err.message || err), body: req.body, headers: req.header, auth: req.headers['x-api-user'], originalUrl: req.originalUrl - }); + });*/ var message = err.message ? err.message : err; message = (message.length < 200) ? message : message.substring(0,100) + message.substring(message.length-100,message.length); res.json(500,{err:message}); //res.end(err.message); From 8852ac6b698ce0a5463f5cbfb4b18815245444ca Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Mon, 2 Feb 2015 19:27:22 -0700 Subject: [PATCH 15/18] feat(change-email): allow changing email address --- public/js/controllers/settingsCtrl.js | 29 ++---------- src/controllers/auth.js | 65 +++++++++++++++++---------- src/routes/auth.js | 1 + views/options/settings.jade | 31 ++++++++----- 4 files changed, 68 insertions(+), 58 deletions(-) diff --git a/public/js/controllers/settingsCtrl.js b/public/js/controllers/settingsCtrl.js index d033749620..9789c90078 100644 --- a/public/js/controllers/settingsCtrl.js +++ b/public/js/controllers/settingsCtrl.js @@ -77,33 +77,12 @@ habitrpg.controller('SettingsCtrl', $rootScope.$state.go('tasks'); } - $scope.changeUsername = function(changeUser){ - if (!changeUser.newUsername || !changeUser.password) { - return alert(window.env.t('fillAll')); - } - $http.post(ApiUrl.get() + '/api/v2/user/change-username', changeUser) + $scope.changeUser = function(attr, updates){ + $http.post(ApiUrl.get() + '/api/v2/user/change-'+attr, updates) .success(function(){ - alert(window.env.t('usernameSuccess')); - $scope.changeUser = {}; + alert(window.env.t(attr+'Success')); + _.each(updates, function(v,k){updates[k]=null;}); User.sync(); - }) - .error(function(data){ - alert(data.err); - }); - } - - $scope.changePassword = function(changePass){ - if (!changePass.oldPassword || !changePass.newPassword || !changePass.confirmNewPassword) { - return alert(window.env.t('fillAll')); - } - $http.post(ApiUrl.get() + '/api/v2/user/change-password', changePass) - .success(function(data, status, headers, config){ - if (data.err) return alert(data.err); - alert(window.env.t('passSuccess')); - $scope.changePass = {}; - }) - .error(function(data, status, headers, config){ - alert(data.err); }); } diff --git a/src/controllers/auth.js b/src/controllers/auth.js index 3a00eda7f0..aaa34026d9 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -23,6 +23,10 @@ var accountSuspended = function(uuid){ code: 'ACCOUNT_SUSPENDED' }; } +// escape email for regex, then search case-insensitive. See http://stackoverflow.com/a/3561711/362790 +var mongoEmailRegex = function(email){ + return new RegExp('^' + email.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$', 'i'); +} api.auth = function(req, res, next) { var uid = req.headers['x-api-user']; @@ -198,9 +202,7 @@ api.resetPassword = function(req, res, next){ newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later) hashed_password = utils.encryptPassword(newPassword, salt); - // escape email for regex, then search case-insensitive. See http://stackoverflow.com/a/3561711/362790 - var emailRegExp = new RegExp('^' + email.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$', 'i'); - User.findOne({'auth.local.email':emailRegExp}, function(err, user){ + User.findOne({'auth.local.email':mongoEmailRegex(email)}, function(err, user){ if (err) return next(err); if (!user) return res.send(500, {err:"Couldn't find a user registered for email " + email}); user.auth.local.salt = salt; @@ -217,28 +219,45 @@ api.resetPassword = function(req, res, next){ }); }; +var invalidPassword = function(user, password){ + var hashed_password = utils.encryptPassword(password, user.auth.local.salt); + if (hashed_password !== user.auth.local.hashed_password) + return {code:401, err:"Incorrect password"}; + return false; +} + api.changeUsername = function(req, res, next) { - var user = res.locals.user, - password = req.body.password, - newUsername = req.body.newUsername; + async.waterfall([ + function(cb){ + User.findOne({'auth.local.username': req.body.username}, {auth:1}, cb); + }, + function(found, cb){ + if (found) return cb({code:401, err: "Username already taken"}); + if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password)); + res.locals.user.auth.local.username = req.body.username; + res.locals.user.save(cb); + } + ], function(err){ + if (err) return err.code ? res.json(err.code, err) : next(err); + res.send(200); + }) +} - User.findOne({'auth.local.username': newUsername}, function(err, result) { - if (err) next(err); - if(result) return res.json(401, {err: "Username already taken"}); - - var salt = user.auth.local.salt; - var hashed_password = utils.encryptPassword(password, salt); - - if (hashed_password !== user.auth.local.hashed_password) - return res.json(401, {err:"Incorrect password"}); - - user.auth.local.username = newUsername; - user.save(function(err, saved){ - if (err) next(err); - res.send(200); - user = password = newUsername = null; - }) - }); +api.changeEmail = function(req, res, next){ + async.waterfall([ + function(cb){ + User.findOne({'auth.local.email': mongoEmailRegex(req.body.email)}, {auth:1}, cb); + }, + function(found, cb){ + if(found) return cb({code:401, err: "Email already taken"}); + if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password)); + res.locals.user.auth.local.email = req.body.email; + res.locals.user.save(cb); + } + ], function(err){ + if (err) return err.code ? res.json(err.code,err) : next(err); + res.send(200); + }) } api.changePassword = function(req, res, next) { diff --git a/src/routes/auth.js b/src/routes/auth.js index 0405154db1..2080c94697 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -11,6 +11,7 @@ router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); +router.post('/api/v2/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser); router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal); diff --git a/views/options/settings.jade b/views/options/settings.jade index e0b150c850..e805b75472 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -100,7 +100,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') |  =env.t('loginNameDescription3') p=env.t('email') - |: {{::user.auth.local.email}} + |: {{user.auth.local.email}} p small.muted =env.t('emailChange1') @@ -109,22 +109,33 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') |  =env.t('emailChange3') hr + h5=env.t('changeUsername') - form(ng-submit='changeUsername(changeUser)', ng-show='user.auth.local') + form(ng-submit='changeUser("username", usernameUpdates)', ng-init='usernameUpdates={}', ng-show='user.auth.local', name='changeUsername', novalidate) + //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll') .form-group - input.form-control(type='text', placeholder=env.t('newUsername'), ng-model='changeUser.newUsername', required) + input.form-control(type='text', placeholder=env.t('newUsername'), ng-model='usernameUpdates.username', required) .form-group - input.form-control(type='password', placeholder=env.t('password'), ng-model='changeUser.password', required) - input.btn.btn-default(type='submit', value=env.t('submit')) + input.form-control(type='password', placeholder=env.t('password'), ng-model='usernameUpdates.password', required) + input.btn.btn-default(type='submit', ng-disabled='changeUsername.$invalid', value=env.t('submit')) + + h5=env.t('changeEmail') + form(ng-submit='changeUser("email", emailUpdates)', ng-show='user.auth.local', name='changeEmail', novalidate) + .form-group + input.form-control(type='text', placeholder=env.t('newEmail'), ng-model='emailUpdates.email', required) + .form-group + input.form-control(type='password', placeholder=env.t('password'), ng-model='emailUpdates.password', required) + input.btn.btn-default(type='submit', ng-disabled='changeEmail.$invalid', value=env.t('submit')) + h5=env.t('changePass') - form(ng-submit='changePassword(changePass)', ng-show='user.auth.local') + form(ng-submit='changeUser("password", passwordUpdates)', ng-show='user.auth.local', name='changePassword', novalidate) .form-group - input.form-control(type='password', placeholder=env.t('oldPass'), ng-model='changePass.oldPassword', required) + input.form-control(type='password', placeholder=env.t('oldPass'), ng-model='passwordUpdates.oldPassword', required) .form-group - input.form-control(type='password', placeholder=env.t('newPass'), ng-model='changePass.newPassword', required) + input.form-control(type='password', placeholder=env.t('newPass'), ng-model='passwordUpdates.newPassword', required) .form-group - input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='changePass.confirmNewPassword', required) - input.btn.btn-default(type='submit', value=env.t('submit')) + input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='passwordUpdates.confirmNewPassword', required) + input.btn.btn-default(type='submit', ng-disabled='changePassword.$invalid', value=env.t('submit')) .panel.panel-default From b013c61b3be7176651b1f8c5725817125045dd7d Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Tue, 3 Feb 2015 15:46:51 -0700 Subject: [PATCH 16/18] feat(change-email): remove blurb about changing email address --- views/options/settings.jade | 7 ------- 1 file changed, 7 deletions(-) diff --git a/views/options/settings.jade b/views/options/settings.jade index 2fa6996038..7822c252a0 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -104,13 +104,6 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') =env.t('loginNameDescription3') p=env.t('email') |: {{user.auth.local.email}} - p - small.muted - =env.t('emailChange1') - |  - a(href='mailto:admin@habitrpg.com')=env.t('emailChange2') - |  - =env.t('emailChange3') hr h5=env.t('changeUsername') From 58af9622d6ab67303d833ac2c0de3d02545267e4 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Tue, 3 Feb 2015 19:05:42 -0600 Subject: [PATCH 17/18] chore(news): Feb backgrounds Bailey --- views/shared/new-stuff.jade | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/views/shared/new-stuff.jade b/views/shared/new-stuff.jade index 75b3e4a908..753b36a65c 100644 --- a/views/shared/new-stuff.jade +++ b/views/shared/new-stuff.jade @@ -1,25 +1,32 @@ -h5 2/2/2015 - FEBRUARY MYSTERY BOX, NEW QUEST DESCRIPTIONS, AND END OF SPREAD THE WORD CHALLENGE +h5 2/3/2015 hr tr td - h5 February Mystery Box - .inventory_present.pull-right - p Ooh... What could it be? All Habiticans who are subscribed during the month of February will receive the February Mystery Item Set! It will be revealed on the 24th, so keep your eyes peeled. Thanks for supporting the site <3 - p.small.muted by Lemoness - tr - td - h5 New Quest Descriptions - p We've updated quest descriptions so that when you hover over them, you can now see the Boss or Collection stats and the Rewards that you will gain when you complete the quest! - p.small.muted by Blade - tr - td - h5 Spread the Word Challenge Has Ended - p The Spread the Word Challenge has ended! Thank you to all the participants. It will be some time before the winners are announced because we have to go over all the entries ourselves. Thanks for your patience! + h5 FEBRUARY BACKGROUNDS REVEALED + .background_distant_castle.pull-right + p There are three new avatar backgrounds in the Background Shop! Now your avatar can survey a Distant Castle, toil in the Blacksmithy, or explore a Crystal Cave! + p.small.muted by Holseties, Hanztan, and Twitching hr a(href='/static/old-news', target='_blank') Read older news mixin oldNews + h5 2/2/2015 + tr + td + h5 February Mystery Box + .inventory_present.pull-right + p Ooh... What could it be? All Habiticans who are subscribed during the month of February will receive the February Mystery Item Set! It will be revealed on the 24th, so keep your eyes peeled. Thanks for supporting the site <3 + p.small.muted by Lemoness + tr + td + h5 New Quest Descriptions + p We've updated quest descriptions so that when you hover over them, you can now see the Boss or Collection stats and the Rewards that you will gain when you complete the quest! + p.small.muted by Blade + tr + td + h5 Spread the Word Challenge Has Ended + p The Spread the Word Challenge has ended! Thank you to all the participants. It will be some time before the winners are announced because we have to go over all the entries ourselves. Thanks for your patience! h5 1/30/2015 tr td From 5ec2c118a5ab51b652915f1a9cfbe3c6ff98785d Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Tue, 3 Feb 2015 19:14:16 -0700 Subject: [PATCH 18/18] feat(facebook-to-local): allow users to move from facebook=>local --- public/js/controllers/rootCtrl.js | 9 +++ src/controllers/auth.js | 95 +++++++++++++++++-------------- src/routes/auth.js | 1 + views/options/settings.jade | 19 ++++++- 4 files changed, 81 insertions(+), 43 deletions(-) diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 4560c58383..efe2862967 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -242,5 +242,14 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ window.location.href = url; window.location.reload(false); } + + // Universal method for sending HTTP methods + $rootScope.http = function(method, route, data, alertMsg){ + $http[method](ApiUrl.get() + route, data).success(function(){ + if (alertMsg) Notification.text(window.env.t(alertMsg)); + User.sync(); + }); + // error will be handled via $http interceptor + } } ]); diff --git a/src/controllers/auth.js b/src/controllers/auth.js index 911c72b0e8..b3fe5e4d3d 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -65,55 +65,57 @@ api.authWithUrl = function(req, res, next) { } api.registerUser = function(req, res, next) { - var confirmPassword = req.body.confirmPassword, - email = req.body.email, - password = req.body.password, - username = req.body.username; - if (!(username && password && email)) return res.json(401, {err: ":username, :email, :password, :confirmPassword required"}); - if (password !== confirmPassword) return res.json(401, {err: ":password and :confirmPassword don't match"}); - if (!validator.isEmail(email)) return res.json(401, {err: ":email invalid"}); - async.waterfall([ - function(cb) { - User.findOne({'auth.local.email': email}, cb); + async.auto({ + validate: function(cb) { + if (!(req.body.username && req.body.password && req.body.email)) + return cb({code:401, err: ":username, :email, :password, :confirmPassword required"}); + if (req.body.password !== req.body.confirmPassword) + return cb({code:401, err: ":password and :confirmPassword don't match"}); + if (!validator.isEmail(req.body.email)) + return cb({code:401, err: ":email invalid"}); + cb(); }, - function(found, cb) { - if (found) return cb("Email already taken"); - User.findOne({'auth.local.username': username}, cb); - }, function(found, cb) { - var newUser, salt, user; - if (found) return cb("Username already taken"); - salt = utils.makeSalt(); - newUser = { + findEmail: function(cb) { + User.findOne({'auth.local.email': req.body.email}, cb); + }, + findUname: function(cb) { + User.findOne({'auth.local.username': req.body.username}, cb); + }, + findFacebook: function(cb){ + User.findOne({_id: req.headers['x-api-user'], apiToken: req.headers['x-api-key']}, {auth:1}, cb); + }, + register: ['validate', 'findEmail', 'findUname', 'findFacebook', function(cb, data) { + if (data.findEmail) return cb({code:401, err:"Email already taken"}); + if (data.findUname) return cb({code:401, err:"Username already taken"}); + var salt = utils.makeSalt(); + var newUser = { auth: { local: { - username: username, - email: email, + username: req.body.username, + email: req.body.email, salt: salt, - hashed_password: utils.encryptPassword(password, salt) + hashed_password: utils.encryptPassword(req.body.password, salt) }, timestamps: {created: +new Date(), loggedIn: +new Date()} } }; - newUser.preferences = newUser.preferences || {}; - newUser.preferences.language = req.language; // User language detected from browser, not saved - user = new User(newUser); - - // temporary for conventions - if (req.subdomains[0] == 'con') { - _.each(user.dailys, function(h){ - h.repeat = {m:false,t:false,w:false,th:false,f:false,s:false,su:false}; - }) - user.extra = {signupEvent: 'wondercon'}; + // existing user, allow them to add local authentication + if (data.findFacebook) { + data.findFacebook.auth.local = newUser.auth.local; + data.findFacebook.save(cb); + // new user, register them + } else { + newUser.preferences = newUser.preferences || {}; + newUser.preferences.language = req.language; // User language detected from browser, not saved + var user = new User(newUser); + utils.txnEmail(user, 'welcome'); + ga.event('register', 'Local').send(); + user.save(cb); } - - user.save(cb); - utils.txnEmail(user, 'welcome'); - ga.event('register', 'Local').send() - } - ], function(err, saved) { - if (err) return res.json(401, {err: err}); - res.json(200, saved); - email = password = username = null; + }] + }, function(err, data) { + if (err) return err.code ? res.json(err.code, err) : next(err); + res.json(200, data.register[0]); }); }; @@ -190,9 +192,18 @@ api.loginSocial = function(req, res, next) { /** * DELETE /user/auth/social - * TODO implement */ -api.deleteSocial = function(req,res,next){next()} +api.deleteSocial = function(req,res,next){ + if (!res.locals.user.auth.local.username) + return res.json(401, {err:"Account lacks another authentication method, can't detach Facebook"}); + //FIXME for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123 + //res.locals.user.auth.facebook = null; + //res.locals.user.auth.save(function(err, saved){ + User.update({_id:res.locals.user._id}, {$unset:{'auth.facebook':1}}, function(err){ + if (err) return next(err); + res.send(200); + }) +} api.resetPassword = function(req, res, next){ var email = req.body.email, diff --git a/src/routes/auth.js b/src/routes/auth.js index 2080c94697..442c919f6b 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -8,6 +8,7 @@ auth.setupPassport(router); //FIXME make this consistent with the others router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser); router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal); router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); +router.delete('/api/v2/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial); router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); diff --git a/views/options/settings.jade b/views/options/settings.jade index 7822c252a0..a5cee15b45 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -91,7 +91,24 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') .panel-heading span Registration .panel-body - p(ng-if='user.auth.facebook.id')=env.t('registeredWithFb') + div(ng-if='user.auth.facebook.id') + button.btn.btn-primary(disabled='disabled', ng-if='!user.auth.local.username')=env.t('registeredWithFb') + button.btn.btn-danger(ng-click='http("delete","/api/v2/user/auth/social",null,"detachedFacebook")', ng-if='user.auth.local.username')=env.t('detachFacebook') + hr + div(ng-if='!user.auth.local.username') + p Add local authentication: + form(ng-submit='http("post","/api/v2/register",localAuth,"addedLocalAuth")', ng-init='localAuth={}', name='localAuth', novalidate) + //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted')=env.t('fillAll') + .form-group + input.form-control(type='text', placeholder=env.t('username'), ng-model='localAuth.username', required) + .form-group + input.form-control(type='text', placeholder=env.t('email'), ng-model='localAuth.email', required) + .form-group + input.form-control(type='password', placeholder=env.t('password'), ng-model='localAuth.password', required) + .form-group + input.form-control(type='password', placeholder=env.t('confirmPass'), ng-model='localAuth.confirmPassword', required) + input.btn.btn-default(type='submit', ng-disabled='localAuth.$invalid', value=env.t('submit')) + div(ng-if='user.auth.local.username') p=env.t('username') |: {{user.auth.local.username}}