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'); 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); +} diff --git a/website/public/js/app.js b/website/public/js/app.js index 3ee68b2a9a..742f31cfcc 100644 --- a/website/public/js/app.js +++ b/website/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/website/public/js/controllers/rootCtrl.js b/website/public/js/controllers/rootCtrl.js index 4560c58383..efe2862967 100644 --- a/website/public/js/controllers/rootCtrl.js +++ b/website/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/website/public/js/controllers/settingsCtrl.js b/website/public/js/controllers/settingsCtrl.js index d033749620..9789c90078 100644 --- a/website/public/js/controllers/settingsCtrl.js +++ b/website/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/website/public/js/directives/directives.js b/website/public/js/directives/directives.js index e4793d7e0c..d3de6d20a6 100644 --- a/website/public/js/directives/directives.js +++ b/website/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/website/src/controllers/auth.js b/website/src/controllers/auth.js index ac553c34a5..a798666d62 100644 --- a/website/src/controllers/auth.js +++ b/website/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']; @@ -61,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); - if(isProd) utils.txnEmail({name:username, email:email}, '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]); }); }; @@ -173,9 +179,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){ @@ -188,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, @@ -198,9 +211,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 +228,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/website/src/controllers/challenges.js b/website/src/controllers/challenges.js index 1f4a7da2e1..5fabd0fb1c 100644 --- a/website/src/controllers/challenges.js +++ b/website/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/website/src/controllers/groups.js b/website/src/controllers/groups.js index 7e02096b86..b6919a386c 100644 --- a/website/src/controllers/groups.js +++ b/website/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/public'} + ); + }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/website/src/controllers/members.js b/website/src/controllers/members.js index 77083428a4..9102df985a 100644 --- a/website/src/controllers/members.js +++ b/website/src/controllers/members.js @@ -5,6 +5,8 @@ var api = module.exports; var async = require('async'); var _ = require('lodash'); var shared = require('../../../common'); +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: req.body.gems.amount} + ]); + } return async.parallel([ function (cb2) { member.save(cb2) }, function (cb2) { user.save(cb2) } diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index 27bc261f80..15263ee0be 100644 --- a/website/src/controllers/payments/index.js +++ b/website/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: data.gift.gems.amount || 20} + ]); + } + } async.parallel([ function(cb2){data.user.save(cb2)}, function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} @@ -137,4 +153,4 @@ exports.paypalCheckoutSuccess = paypal.executePayment; exports.paypalIPN = paypal.ipn; exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; +exports.iapIosVerify = iap.iosVerify; \ No newline at end of file diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index c705fe7553..84e7107170 100644 --- a/website/src/controllers/user.js +++ b/website/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 @@ -437,16 +415,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} - ]; - // TODO implement "users can only be invited once" - utils.txnEmail(invite, 'invite-friend', variables); + + 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 || (userToContact.preferences.emailNotifications.invitedParty !== false && + userToContact.preferences.emailNotifications.unsubscribeFromAll !== true)){ + // TODO implement "users can only be invited once" + utils.txnEmail(invite, 'invite-friend', variables); + } + }); + } }); res.send(200); @@ -477,7 +473,7 @@ api.sessionPartyInvite = function(req,res,next){ } /** - * All other user.ops which can easily be mapped to ../../common/scripts/index.coffee, not requiring custom API-wrapping + * All other user.ops which can easily be mapped to habitrpg-shared/index.coffee, not requiring custom API-wrapping */ _.each(shared.wrap({}).ops, function(op,k){ if (!api[k]) { diff --git a/website/src/logging.js b/website/src/logging.js index 1f4a1d771e..16b7b497f2 100644 --- a/website/src/logging.js +++ b/website/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/website/src/middleware.js b/website/src/middleware.js index f44c67a9e3..f597d0546c 100644 --- a/website/src/middleware.js +++ b/website/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); diff --git a/website/src/models/user.js b/website/src/models/user.js index 86374cd959..3030f0c2de 100644 --- a/website/src/models/user.js +++ b/website/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/website/src/routes/auth.js b/website/src/routes/auth.js index 0405154db1..442c919f6b 100644 --- a/website/src/routes/auth.js +++ b/website/src/routes/auth.js @@ -8,9 +8,11 @@ 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); +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/website/src/utils.js b/website/src/utils.js index 613cde9bdd..55cab68ede 100644 --- a/website/src/utils.js +++ b/website/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/website/views/options/inventory/inventory.jade b/website/views/options/inventory/inventory.jade index 5fc6dc9de5..c8d066ba93 100644 --- a/website/views/options/inventory/inventory.jade +++ b/website/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', {linkStart:"", linkEnd: ""}) + // 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/website/views/options/profile.jade b/website/views/options/profile.jade index 300d207e0c..c2250f06b8 100644 --- a/website/views/options/profile.jade +++ b/website/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')) diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade index b6208ffc88..a5cee15b45 100644 --- a/website/views/options/settings.jade +++ b/website/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,11 +86,29 @@ 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 .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}} @@ -100,31 +120,35 @@ 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') + |: {{user.auth.local.email}} 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 @@ -242,6 +266,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/website/views/options/social/challenges.jade b/website/views/options/social/challenges.jade index 1224cfae41..1f93754220 100644 --- a/website/views/options/social/challenges.jade +++ b/website/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/website/views/shared/header/menu.jade b/website/views/shared/header/menu.jade index 549af9c4e5..a08920b3af 100644 --- a/website/views/shared/header/menu.jade +++ b/website/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') diff --git a/website/views/shared/modals/members.jade b/website/views/shared/modals/members.jade index aa1cb11ddd..bafe6c62f5 100644 --- a/website/views/shared/modals/members.jade +++ b/website/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 diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade index b79c09166b..753b36a65c 100644 --- a/website/views/shared/new-stuff.jade +++ b/website/views/shared/new-stuff.jade @@ -1,31 +1,54 @@ -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/3/2015 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! - 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 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 + .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 diff --git a/website/views/shared/tasks/task.jade b/website/views/shared/tasks/task.jade index b50c04460c..9350697474 100644 --- a/website/views/shared/tasks/task.jade +++ b/website/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