From df028b32bbade84578fad0188309ffdbfd6b1d7b Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Fri, 15 Jul 2016 10:15:45 -0700 Subject: [PATCH 1/6] Add compiler requirements to fix npm update --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index 36aa05ede..140102729 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,13 +4,18 @@ language: node_js node_js: - 5.1.1 + +env: + - CXX=g++-4.8 addons: apt: sources: - mongodb-upstart + - ubuntu-toolchain-r-test packages: - mongodb-org-server + - g++-4.8 cache: directories: From 0922eec2cc5f78a96a5528b89897c7cfbd296142 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Fri, 15 Jul 2016 14:47:32 -0700 Subject: [PATCH 2/6] Add stubs for game-dev-1, web-dev-1, and web-dev-2 to updateCourses script --- scripts/mongodb/updateCourses.js | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/scripts/mongodb/updateCourses.js b/scripts/mongodb/updateCourses.js index e1c5d6f53..c569904b9 100644 --- a/scripts/mongodb/updateCourses.js +++ b/scripts/mongodb/updateCourses.js @@ -52,6 +52,36 @@ var courses = duration: NumberInt(5), free: false, screenshot: "/images/pages/courses/105_info.png" + }, + { + name: "CS: Game Development 1", + slug: "game-dev-1", + campaignID: ObjectId("5789236960deed1f00ec2ab8"), + description: "Learn to create your owns games which you can share with your friends.", + duration: NumberInt(5), + free: false, + //screenshot: "/images/pages/courses/105_info.png", + adminOnly: true + }, + { + name: "CS: Web Development 1", + slug: "web-dev-1", + campaignID: ObjectId("578913f2c8871ac2326fa3e4"), + description: "Learn the basics of web development in this introductory HTML & CSS course.", + duration: NumberInt(5), + free: false, + //screenshot: "/images/pages/courses/105_info.png", + adminOnly: true + }, + { + name: "CS: Web Development 2", + slug: "web-dev-2", + campaignID: ObjectId("57891570c8871ac2326fa3f8"), + description: "Learn more advanced web development, including scripting to make interactive webpages.", + duration: NumberInt(5), + free: false, + //screenshot: "/images/pages/courses/105_info.png", + adminOnly: true } ]; @@ -62,7 +92,7 @@ for (var i = 0; i < courses.length; i++) { if (cursor.hasNext()) { var doc = cursor.next(); for (var levelID in doc.levels) { - for (var j = 0; j < doc.levels[levelID].concepts.length; j++) { + for (var j = 0; j < (doc.levels[levelID].concepts || []).length; j++) { concepts[doc.levels[levelID].concepts[j]] = true; } } From 607c129c7f66c3d071c3be843aeb8a7028805251 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Mon, 18 Jul 2016 09:41:42 -0700 Subject: [PATCH 3/6] School active licenses admin page --- app/core/Router.coffee | 3 +- app/styles/admin/admin-school-licenses.sass | 21 ++++++ app/templates/admin.jade | 2 + app/templates/admin/school-licenses.jade | 39 +++++++++++ app/views/admin/SchoolLicensesView.coffee | 72 +++++++++++++++++++++ server/middleware/prepaids.coffee | 68 +++++++++++++++---- server/routes/index.coffee | 1 + 7 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 app/styles/admin/admin-school-licenses.sass create mode 100644 app/templates/admin/school-licenses.jade create mode 100644 app/views/admin/SchoolLicensesView.coffee diff --git a/app/core/Router.coffee b/app/core/Router.coffee index bec320c7b..eeb42e2c2 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -34,9 +34,10 @@ module.exports = class CocoRouter extends Backbone.Router 'admin/design-elements': go('admin/DesignElementsView') 'admin/files': go('admin/FilesView') 'admin/analytics': go('admin/AnalyticsView') - 'admin/school-counts': go('admin/SchoolCountsView') 'admin/analytics/subscriptions': go('admin/AnalyticsSubscriptionsView') 'admin/level-sessions': go('admin/LevelSessionsView') + 'admin/school-counts': go('admin/SchoolCountsView') + 'admin/school-licenses': go('admin/SchoolLicensesView') 'admin/users': go('admin/UsersView') 'admin/base': go('admin/BaseView') 'admin/demo-requests': go('admin/DemoRequestsView') diff --git a/app/styles/admin/admin-school-licenses.sass b/app/styles/admin/admin-school-licenses.sass new file mode 100644 index 000000000..74e9518a8 --- /dev/null +++ b/app/styles/admin/admin-school-licenses.sass @@ -0,0 +1,21 @@ +#admin-school-licenses-view + + table + td, th + padding: 0px + + .range-container + position: relative + width: 100% + .range-background + position: absolute + height: 100% + left: 0px + top: 0px + background-color: green + opacity: 0.25 + .range-dates + position: absolute + height: 100% + left: 0px + top: 0px diff --git a/app/templates/admin.jade b/app/templates/admin.jade index cd323fc39..6dc2c8e3d 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -47,6 +47,8 @@ block content input.classroom-progress-class-code(type=text value="") li a(href="/admin/analytics") Dashboard + li + a(href="/admin/school-licenses") School Active Licenses li a(href="/admin/school-counts") School Counts li diff --git a/app/templates/admin/school-licenses.jade b/app/templates/admin/school-licenses.jade new file mode 100644 index 000000000..a6d4edffb --- /dev/null +++ b/app/templates/admin/school-licenses.jade @@ -0,0 +1,39 @@ +extends /templates/base-flat + +//- DO NOT TRANSLATE + +block content + + if !me.isAdmin() + div You must be logged in as an admin to view this page. + else if !view.schools + h3 Loading... + else + h3 School Active Licenses + .small Max: total licenses + .small Used: licenses redeemed + .small Activity: level sessions created in last 30 days + table.table.table-condensed + thead + th School + th Max + th Used  + th Activity + tr + td(style="height:26px;").range-container + each rangeKey in view.rangeKeys + span.range-background(style="left:#{rangeKey.startScale}%;width:#{rangeKey.width}%;background-color:#{rangeKey.color}") + span.range-dates(style="left:#{rangeKey.startScale}%;width:#{rangeKey.width}%;") #{rangeKey.name} + td(colspan=2) + each school in view.schools + each prepaid in school.prepaids + tr + td.range-container + span.range-background(style="left:#{prepaid.startScale}%;width:#{prepaid.rangeScale}%;") + span.range-dates(style="left:#{prepaid.startScale}%;") + span.spr #{prepaid.startDate.substring(0, 10)} + strong.spr #{school.name} + span #{prepaid.endDate.substring(0, 10)} + td #{prepaid.max}  + td #{prepaid.used} + td= school.activity diff --git a/app/views/admin/SchoolLicensesView.coffee b/app/views/admin/SchoolLicensesView.coffee new file mode 100644 index 000000000..f3b1d2c9d --- /dev/null +++ b/app/views/admin/SchoolLicensesView.coffee @@ -0,0 +1,72 @@ +RootView = require 'views/core/RootView' +CocoCollection = require 'collections/CocoCollection' +Prepaid = require 'models/Prepaid' +TrialRequests = require 'collections/TrialRequests' + +# TODO: year ranges hard-coded + +module.exports = class SchoolLicensesView extends RootView + id: 'admin-school-licenses-view' + template: require 'templates/admin/school-licenses' + + initialize: -> + return super() unless me.isAdmin() + @startDateRange = new Date() + @endDateRange = new Date() + @endDateRange.setUTCFullYear(@endDateRange.getUTCFullYear() + 2) + @supermodel.addRequestResource({ + url: '/db/prepaid/-/active-schools' + method: 'GET' + success: ({prepaidActivityMap, schoolPrepaidsMap}) => + @updateSchools(prepaidActivityMap, schoolPrepaidsMap) + }, 0).load() + super() + + updateSchools: (prepaidActivityMap, schoolPrepaidsMap) -> + timeStart = @startDateRange.getTime() + time2017 = new Date('2017').getTime() + time2018 = new Date('2018').getTime() + timeEnd = @endDateRange.getTime() + rangeMilliseconds = timeEnd - timeStart + @rangeKeys = [ + {name :'Today', color: 'blue', startScale: 0, width: Math.round((time2017 - timeStart) / rangeMilliseconds * 100)} + {name: '2017', color: 'red', startScale: Math.round((time2017 - timeStart) / rangeMilliseconds * 100), width: Math.round((time2018 - time2017) / rangeMilliseconds * 100)} + {name: '2018', color: 'yellow', startScale: Math.round((time2018 - timeStart) / rangeMilliseconds * 100), width: Math.round((timeEnd - time2018) / rangeMilliseconds * 100)} + ] + + @schools = [] + for school, prepaids of schoolPrepaidsMap + activity = 0 + schoolMax = 0 + schoolUsed = 0 + collapsedPrepaids = [] + for prepaid in prepaids + activity += prepaidActivityMap[prepaid._id] ? 0 + startDate = prepaid.startDate + endDate = prepaid.endDate + max = parseInt(prepaid.maxRedeemers) + used = parseInt(prepaid.redeemers?.length ? 0) + schoolMax += max + schoolUsed += used + foundIdenticalDates = false + for collapsedPrepaid in collapsedPrepaids + if collapsedPrepaid.startDate.substring(0, 10) is startDate.substring(0, 10) and collapsedPrepaid.endDate.substring(0, 10) is endDate.substring(0, 10) + collapsedPrepaid.max += parseInt(prepaid.maxRedeemers) + collapsedPrepaid.used += parseInt(prepaid.redeemers?.length ? 0) + foundIdenticalDates = true + break + unless foundIdenticalDates + collapsedPrepaids.push({startDate, endDate, max, used}) + + for collapsedPrepaid in collapsedPrepaids + collapsedPrepaid.startScale = Math.round((new Date(collapsedPrepaid.startDate).getTime() - @startDateRange.getTime()) / rangeMilliseconds * 100) + collapsedPrepaid.startScale = 0 if collapsedPrepaid.startScale < 0 + collapsedPrepaid.rangeScale = Math.round((new Date(collapsedPrepaid.endDate).getTime() - new Date(collapsedPrepaid.startDate).getTime()) / rangeMilliseconds * 100) + collapsedPrepaid.rangeScale = 100 - collapsedPrepaid.startScale if collapsedPrepaid.rangeScale + collapsedPrepaid.startScale > 100 + @schools.push {name: school, activity, max: schoolMax, used: schoolUsed, prepaids: collapsedPrepaids, startDate: collapsedPrepaids[0].startDate, endDate: collapsedPrepaids[0].endDate} + + @schools.sort (a, b) -> + b.activity - a.activity or new Date(a.endDate).getTime() - new Date(b.endDate).getTime() or b.max - a.max or b.used - a.used or b.prepaids.length - a.prepaids.length or b.name.localeCompare(a.name) + + # console.log @schools + @render() diff --git a/server/middleware/prepaids.coffee b/server/middleware/prepaids.coffee index 09593e050..80192ddac 100644 --- a/server/middleware/prepaids.coffee +++ b/server/middleware/prepaids.coffee @@ -1,9 +1,11 @@ wrap = require 'co-express' errors = require '../commons/errors' database = require '../commons/database' -Prepaid = require '../models/Prepaid' -User = require '../models/User' mongoose = require 'mongoose' +LevelSession = require '../models/LevelSession' +Prepaid = require '../models/Prepaid' +TrialRequest = require '../models/TrialRequest' +User = require '../models/User' cutoffDate = new Date(2015,11,11) cutoffID = mongoose.Types.ObjectId(Math.floor(cutoffDate/1000).toString(16)+'0000000000000000') @@ -11,14 +13,14 @@ cutoffID = mongoose.Types.ObjectId(Math.floor(cutoffDate/1000).toString(16)+'000 module.exports = logError: (user, msg) -> console.warn "Prepaid Error: [#{user.get('slug')} (#{user._id})] '#{msg}'" - - + + post: wrap (req, res) -> validTypes = ['course'] unless req.body.type in validTypes throw new errors.UnprocessableEntity("type must be on of: #{validTypes}.") # TODO: deprecate or refactor other prepaid types - + if req.body.creator user = yield User.search(req.body.creator) if not user @@ -32,16 +34,16 @@ module.exports = database.validateDoc(prepaid) yield prepaid.save() res.status(201).send(prepaid.toObject()) - + redeem: wrap (req, res) -> if not req.user?.isTeacher() throw new errors.Forbidden('Must be a teacher to use licenses') - + prepaid = yield database.getDocFromHandle(req, Prepaid) if not prepaid throw new errors.NotFound('Prepaid not found.') - + if prepaid._id.getTimestamp().getTime() < cutoffDate.getTime() throw new errors.Forbidden('Cannot redeem from prepaids older than November 11, 2015') unless prepaid.get('creator').equals(req.user._id) @@ -61,7 +63,7 @@ module.exports = return res.status(200).send(prepaid.toObject({req: req})) if user.isTeacher() throw new errors.Forbidden('Teachers may not be enrolled') - + query = _id: prepaid._id 'redeemers.userID': { $ne: user._id } @@ -71,7 +73,7 @@ module.exports = if result.nModified is 0 @logError(req.user, "POST prepaid redeemer lost race on maxRedeemers") throw new errors.Forbidden('This prepaid is exhausted') - + update = { $set: { coursePrepaid: { @@ -84,7 +86,7 @@ module.exports = if not user.get('role') update.$set.role = 'student' yield user.update(update) - + # return prepaid with new redeemer added locally redeemers = _.clone(prepaid.get('redeemers') or []) redeemers.push({ date: new Date(), userID: user._id }) @@ -94,12 +96,12 @@ module.exports = fetchByCreator: wrap (req, res, next) -> creator = req.query.creator return next() if not creator - + unless req.user.isAdmin() or creator is req.user.id throw new errors.Forbidden('Must be logged in as given creator') unless database.isID(creator) throw new errors.UnprocessableEntity('Invalid creator') - + q = { _id: { $gt: cutoffID } creator: mongoose.Types.ObjectId(creator) @@ -108,3 +110,43 @@ module.exports = prepaids = yield Prepaid.find(q) res.send((prepaid.toObject({req: req}) for prepaid in prepaids)) + + fetchActiveSchools: wrap (req, res) -> + unless req.user.isAdmin() or creator is req.user.id + throw new errors.Forbidden('Must be logged in as given creator') + prepaids = yield Prepaid.find({type: 'course'}, {creator: 1, properties: 1, startDate: 1, endDate: 1, maxRedeemers: 1, redeemers: 1}).lean() + userPrepaidsMap = {} + today = new Date() + userIDs = [] + redeemerIDs = [] + redeemerPrepaidMap = {} + for prepaid in prepaids + continue if new Date(prepaid.endDate ? prepaid.properties?.endDate ? '2000') < today + continue if new Date(prepaid.endDate) < new Date(prepaid.startDate) + userPrepaidsMap[prepaid.creator.valueOf()] ?= [] + userPrepaidsMap[prepaid.creator.valueOf()].push(prepaid) + userIDs.push prepaid.creator + for redeemer in prepaid.redeemers ? [] + redeemerIDs.push redeemer.userID + "" + redeemerPrepaidMap[redeemer.userID + ""] = prepaid._id.valueOf() + + # Find recently created level sessions for redeemers + lastMonth = new Date() + lastMonth.setUTCDate(lastMonth.getUTCDate() - 30) + levelSessions = yield LevelSession.find({$and: [{created: {$gte: lastMonth}}, {creator: {$in: redeemerIDs}}]}, {creator: 1}).lean() + prepaidActivityMap = {} + for levelSession in levelSessions + prepaidActivityMap[redeemerPrepaidMap[levelSession.creator.valueOf()]] ?= 0 + prepaidActivityMap[redeemerPrepaidMap[levelSession.creator.valueOf()]]++ + + trialRequests = yield TrialRequest.find({$and: [{type: 'course'}, {applicant: {$in: userIDs}}]}, {applicant: 1, properties: 1}).lean() + schoolPrepaidsMap = {} + for trialRequest in trialRequests + school = trialRequest.properties?.organization ? trialRequest.properties?.school + continue unless school + if userPrepaidsMap[trialRequest.applicant.valueOf()]?.length > 0 + schoolPrepaidsMap[school] ?= [] + for prepaid in userPrepaidsMap[trialRequest.applicant.valueOf()] + schoolPrepaidsMap[school].push prepaid + + res.send({prepaidActivityMap, schoolPrepaidsMap}) diff --git a/server/routes/index.coffee b/server/routes/index.coffee index ecca4a2c9..67e6d2c26 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -104,6 +104,7 @@ module.exports.setup = (app) -> app.post('/db/user/:handle/signup-with-password', mw.users.signupWithPassword) app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator) + app.get('/db/prepaid/-/active-schools', mw.auth.checkHasPermission(['admin']), mw.prepaids.fetchActiveSchools) app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post) app.post('/db/prepaid/:handle/redeemers', mw.prepaids.redeem) From 2852f5014ccd8a82afada9e2b7920e99da310c15 Mon Sep 17 00:00:00 2001 From: Bryukhanov Valentin Date: Mon, 18 Jul 2016 19:54:20 +0300 Subject: [PATCH 4/6] Update ru.coffee - hints ui Add "hints" translations in the editor UI --- app/locale/ru.coffee | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/locale/ru.coffee b/app/locale/ru.coffee index 3324871ba..b3528be59 100644 --- a/app/locale/ru.coffee +++ b/app/locale/ru.coffee @@ -438,6 +438,8 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi tome_available_spells: "Доступные заклинания" tome_your_skills: "Ваши навыки" tome_current_method: "Текущий метод" + hints: "Советы" + hints_title: "Совет {{number}}" code_saved: "Код сохранен" skip_tutorial: "Пропуск (Esc)" keyboard_shortcuts: "Горячие клавиши" From 68ebfa0e397bbfcbd25f313dae30ecba78d5cdb0 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Mon, 18 Jul 2016 10:08:23 -0700 Subject: [PATCH 5/6] Update license inquiry call task name --- server/lib/closeIO.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/closeIO.coffee b/server/lib/closeIO.coffee index ece7a1951..906898eb1 100644 --- a/server/lib/closeIO.coffee +++ b/server/lib/closeIO.coffee @@ -115,7 +115,7 @@ module.exports = _type: "lead" lead_id: leadID assigned_to: userID - text: "Call #{teacherEmail}" + text: "Call license inquiry #{teacherEmail}" is_complete: false options = uri: "https://#{apiKey}:X@app.close.io/api/v1/task/" From bb6262483f32ec6c3791ad665e1626dff9a1a040 Mon Sep 17 00:00:00 2001 From: phoenixeliot Date: Wed, 13 Jul 2016 16:50:03 -0700 Subject: [PATCH 6/6] Allow username-only signup for classroom users Address some code review feedback Correct error code in test Don't try to send emails to empty addresses Add tests for subscriptions Add tests for Next Steps email Fix specs Add reason for disabled test --- app/locale/en.coffee | 4 +- app/models/User.coffee | 4 +- .../create-account-modal/basic-info-view.sass | 3 + .../account/account-settings-view.jade | 10 +-- app/templates/admin.jade | 4 +- .../create-account-modal/basic-info-view.jade | 6 ++ .../courses/courses-update-account-view.jade | 2 +- .../courses/remove-student-modal.jade | 5 +- app/views/account/AccountSettingsView.coffee | 21 ++++-- .../CreateAccountModal/BasicInfoView.coffee | 23 ++++--- app/views/courses/TeacherClassView.coffee | 2 +- scripts/mail.coffee | 2 + server/commons/Handler.coffee | 3 + server/commons/errors.coffee | 2 +- server/handlers/patch_handler.coffee | 3 +- server/handlers/subscription_handler.coffee | 3 + server/handlers/user_handler.coffee | 4 +- server/middleware/users.coffee | 16 +++-- server/middleware/versions.coffee | 1 + server/models/User.coffee | 18 ++++- server/queues/scoring/createNewTask.coffee | 2 +- .../scoring/dispatchTaskToConsumer.coffee | 2 +- server/routes/mail.coffee | 9 ++- spec/server/functional/mail.spec.coffee | 30 ++++++++ .../functional/subscription.spec.coffee | 16 +++-- spec/server/functional/user.spec.coffee | 69 ++++++++++++++++++- spec/server/utils.coffee | 7 +- 27 files changed, 215 insertions(+), 56 deletions(-) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 143010bb6..edd918388 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -961,7 +961,7 @@ manage_subscription: "Click here to manage your subscription." new_password: "New Password" new_password_verify: "Verify" - type_in_email: "Type in your email to confirm account deletion." + type_in_email: "Type in your email or username to confirm account deletion." # {change} type_in_email_progress: "Type in your email to confirm deleting your progress." type_in_password: "Also, type in your password." email_subscriptions: "Email Subscriptions" @@ -1333,7 +1333,7 @@ update_account_title: "Your account needs attention!" update_account_blurb: "Before you can access your classes, choose how you want to use this account." update_account_current_type: "Current Account Type:" - update_account_account_email: "Account Email:" + update_account_account_email: "Account Email/Username:" # {change} update_account_am_teacher: "I am a teacher" update_account_keep_access: "Keep access to classes I've created" update_account_teachers_can: "Teacher accounts can:" diff --git a/app/models/User.coffee b/app/models/User.coffee index d7cb01a3a..bfc956f27 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -271,11 +271,11 @@ module.exports = class User extends CocoModel window.location.reload() @fetch(options) - signupWithPassword: (email, password, options={}) -> + signupWithPassword: (name, email, password, options={}) -> options.url = _.result(@, 'url') + '/signup-with-password' options.type = 'POST' options.data ?= {} - _.extend(options.data, {email, password}) + _.extend(options.data, {name, email, password}) jqxhr = @fetch(options) jqxhr.then -> window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'CodeCombat' diff --git a/app/styles/modal/create-account-modal/basic-info-view.sass b/app/styles/modal/create-account-modal/basic-info-view.sass index cf4a202d0..ff0a9ba12 100644 --- a/app/styles/modal/create-account-modal/basic-info-view.sass +++ b/app/styles/modal/create-account-modal/basic-info-view.sass @@ -35,6 +35,9 @@ .help-block margin: 0 + + .optional-help-block + font-style: italic .form-container width: 800px diff --git a/app/templates/account/account-settings-view.jade b/app/templates/account/account-settings-view.jade index 315a5169b..ec9d4826b 100644 --- a/app/templates/account/account-settings-view.jade +++ b/app/templates/account/account-settings-view.jade @@ -10,7 +10,7 @@ else .panel-body .form - var name = me.get('name') || ''; - - var email = me.get('email'); + - var email = me.get('email') || ''; - var admin = me.get('permissions', true).indexOf('admin') != -1; - var godmode = me.get('permissions', true).indexOf('godmode') != -1; .form-group @@ -71,11 +71,11 @@ else .panel-body .form#delete-account-form .form-group - label.control-label(for="email1", data-i18n="account_settings.type_in_email") - input#email1.form-control(name="email", type="email") + label.control-label(for="delete-account-email-or-username", data-i18n="account_settings.type_in_email") + input#delete-account-email-or-username.form-control(name="emailOrUsername") .form-group - label.control-label(for="password1", data-i18n="account_settings.type_in_password") - input#password1.form-control(name="password", type="password") + label.control-label(for="delete-account-password", data-i18n="account_settings.type_in_password") + input#delete-account-password.form-control(name="password", type="password") button#delete-account-btn.btn.form-control.btn-primary(data-i18n="account_settings.delete_this_account") .col-md-6 diff --git a/app/templates/admin.jade b/app/templates/admin.jade index 6dc2c8e3d..0e66c2128 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -10,10 +10,10 @@ block content .col-sm-1 button.btn.btn-primary.btn-large#enter-espionage-mode 007 label.control-label.col-sm-5(for="espionage-name-or-email") - em you are currently #{me.get('name')} at #{me.get('email')} + em you are currently #{me.get('name') || '(no username)'} at #{me.get('email') || '(no email)'} if view.amActually br - em but you are actually #{view.amActually.get('name')} at #{view.amActually.get('email')} + em but you are actually #{view.amActually.get('name') || '(no username)'} at #{view.amActually.get('email') || '(no email)'} br button#stop-spying-btn.btn.btn-xs Stop Spying form#user-search-form.form-group diff --git a/app/templates/core/create-account-modal/basic-info-view.jade b/app/templates/core/create-account-modal/basic-info-view.jade index 193b34349..4b1172cdf 100644 --- a/app/templates/core/create-account-modal/basic-info-view.jade +++ b/app/templates/core/create-account-modal/basic-info-view.jade @@ -36,6 +36,9 @@ form#basic-info-form.modal-body.basic-info span(data-i18n="share_progress_modal.form_label") .col-xs-5.col-xs-offset-3 input.form-control.input-lg#email-input(name="email" type="email") + if view.signupState.get('path') === 'student' + .help-block.optional-help-block.pull-right + span(data-i18n="signup.optional") .col-xs-4.email-check - var checkEmailState = view.state.get('checkEmailState'); if checkEmailState === 'checking' @@ -53,6 +56,7 @@ form#basic-info-form.modal-body.basic-info span.text-forest.glyphicon.glyphicon-ok-circle =" " span(data-i18n="signup.email_good") + .form-group .row .col-xs-7.col-xs-offset-3 @@ -74,6 +78,7 @@ form#basic-info-form.modal-body.basic-info span.text-forest.glyphicon.glyphicon-ok-circle =" " span(data-i18n="signup.name_available") + .form-group .row .col-xs-7.col-xs-offset-3 @@ -81,6 +86,7 @@ form#basic-info-form.modal-body.basic-info span(data-i18n="general.password") .col-xs-5.col-xs-offset-3 input.form-control.input-lg#password-input(name="password" type="password") + .form-group.checkbox.subscribe .row .col-xs-7.col-xs-offset-3 diff --git a/app/templates/courses/courses-update-account-view.jade b/app/templates/courses/courses-update-account-view.jade index e7a2b5dac..765780987 100644 --- a/app/templates/courses/courses-update-account-view.jade +++ b/app/templates/courses/courses-update-account-view.jade @@ -27,7 +27,7 @@ block content if view.accountType div #{view.accountType} div - span.spr #{me.get('email')} + span.spr #{me.get('email') || me.get('name')} span.not_you span.spr(data-i18n="courses.not_you") a.logout-btn(data-i18n="login.log_out", href="#") diff --git a/app/templates/courses/remove-student-modal.jade b/app/templates/courses/remove-student-modal.jade index c831df2f7..4fdde6c0c 100644 --- a/app/templates/courses/remove-student-modal.jade +++ b/app/templates/courses/remove-student-modal.jade @@ -4,7 +4,10 @@ block modal-header-content .text-center h1.modal-title(data-i18n="courses.remove_student1") span.glyphicon.glyphicon-warning-sign.text-danger - p= view.user.get('name', true) + ' - ' + view.user.get('email') + p + span= view.user.get('name', true) + if view.user.get('email') + span= " — " + view.user.get('email') h2(data-i18n="courses.are_you_sure") block modal-body-content diff --git a/app/views/account/AccountSettingsView.coffee b/app/views/account/AccountSettingsView.coffee index cfadd285a..c29034d93 100644 --- a/app/views/account/AccountSettingsView.coffee +++ b/app/views/account/AccountSettingsView.coffee @@ -87,16 +87,16 @@ module.exports = class AccountSettingsView extends CocoView validateCredentialsForDestruction: ($form, onSuccess) -> forms.clearFormAlerts($form) - enteredEmail = $form.find('input[type="email"]').val() - enteredPassword = $form.find('input[type="password"]').val() - if enteredEmail and enteredEmail is me.get('email') + enteredEmailOrUsername = $form.find('input[name="emailOrUsername"]').val() + enteredPassword = $form.find('input[name="password"]').val() + if enteredEmailOrUsername and enteredEmailOrUsername in [me.get('email'), me.get('name')] isPasswordCorrect = false toBeDelayed = true $.ajax url: '/auth/login' type: 'POST' data: - username: enteredEmail + username: enteredEmailOrUsername password: enteredPassword parse: true error: (error) -> @@ -225,9 +225,16 @@ module.exports = class AccountSettingsView extends CocoView return unless res res.error => - errors = JSON.parse(res.responseText) - forms.applyErrorsToForm(@$el, errors) - $('.nano').nanoScroller({scrollTo: @$el.find('.has-error')}) + if res.responseJSON?.property + errors = res.responseJSON + forms.applyErrorsToForm(@$el, errors) + $('.nano').nanoScroller({scrollTo: @$el.find('.has-error')}) + else + noty + text: res.responseText + type: 'error' + layout: 'topCenter' + timeout: 5000 @trigger 'save-user-error' res.success (model, response, options) => @trigger 'save-user-success' diff --git a/app/views/core/CreateAccountModal/BasicInfoView.coffee b/app/views/core/CreateAccountModal/BasicInfoView.coffee index d33993706..051b765a7 100644 --- a/app/views/core/CreateAccountModal/BasicInfoView.coffee +++ b/app/views/core/CreateAccountModal/BasicInfoView.coffee @@ -64,7 +64,8 @@ module.exports = class BasicInfoView extends CocoView checkEmail: -> email = @$('[name="email"]').val() - if email is @state.get('checkEmailValue') + + if @signupState.get('path') isnt 'student' and (not _.isEmpty(email) and email is @state.get('checkEmailValue')) return @state.get('checkEmailPromise') if not (email and forms.validateEmail(email)) @@ -155,7 +156,7 @@ module.exports = class BasicInfoView extends CocoView email: User.schema.properties.email name: User.schema.properties.name password: User.schema.properties.password - required: ['email', 'name', 'password'].concat (if @signupState.get('path') is 'student' then ['firstName', 'lastName'] else []) + required: ['name', 'password'].concat (if @signupState.get('path') is 'student' then ['firstName', 'lastName'] else ['email']) onClickBackButton: -> @trigger 'nav-back' @@ -176,20 +177,20 @@ module.exports = class BasicInfoView extends CocoView @checkEmail() .then @checkName() .then => - if not (@state.get('checkEmailState') is 'available' and @state.get('checkNameState') is 'available') + if not (@state.get('checkEmailState') in ['available', 'standby'] and @state.get('checkNameState') is 'available') throw AbortError - + # update User emails = _.assign({}, me.get('emails')) emails.generalNews ?= {} - emails.generalNews.enabled = @$('#subscribe-input').is(':checked') + emails.generalNews.enabled = @$('#subscribe-input').is(':checked') and not _.isEmpty(@state.get('checkEmailValue')) me.set('emails', emails) unless _.isNaN(@signupState.get('birthday').getTime()) me.set('birthday', @signupState.get('birthday').toISOString()) me.set(_.omit(@signupState.get('ssoAttrs') or {}, 'email', 'facebookID', 'gplusID')) - me.set('name', @$('input[name="name"]').val()) + jqxhr = me.save() if not jqxhr console.error(me.validationError) @@ -203,13 +204,15 @@ module.exports = class BasicInfoView extends CocoView switch @signupState.get('ssoUsed') when 'gplus' { email, gplusID } = @signupState.get('ssoAttrs') - jqxhr = me.signupWithGPlus(email, gplusID) + { name } = forms.formToObject(@$el) + jqxhr = me.signupWithGPlus(name, email, gplusID) when 'facebook' { email, facebookID } = @signupState.get('ssoAttrs') - jqxhr = me.signupWithFacebook(email, facebookID) + { name } = forms.formToObject(@$el) + jqxhr = me.signupWithFacebook(name, email, facebookID) else - { email, password } = forms.formToObject(@$el) - jqxhr = me.signupWithPassword(email, password) + { name, email, password } = forms.formToObject(@$el) + jqxhr = me.signupWithPassword(name, email, password) return new Promise(jqxhr.then) diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index 05318572a..711ef4c82 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -373,7 +373,7 @@ module.exports = class TeacherClassView extends RootView coursePlaytimesString += "0," else coursePlaytimesString += "#{moment.duration(coursePlaytime.playtime, 'seconds').humanize()}," - csvContent += "#{student.get('name')},#{student.get('email')},#{playtimeString},#{coursePlaytimesString}\"#{conceptsString}\"\n" + csvContent += "#{student.get('name')},#{student.get('email') or ''},#{playtimeString},#{coursePlaytimesString}\"#{conceptsString}\"\n" csvContent = csvContent.substring(0, csvContent.length - 1) encodedUri = encodeURI(csvContent) window.open(encodedUri) diff --git a/scripts/mail.coffee b/scripts/mail.coffee index c73d8ee41..cd341ef31 100644 --- a/scripts/mail.coffee +++ b/scripts/mail.coffee @@ -81,6 +81,7 @@ grabUser = (session, callback) -> totalEmailsSent = 0 emailUserInitialRecruiting = (user, callback) -> + return callback null, false if not user.email #return callback null, false if user.emails?.anyNotes?.enabled is false # TODO: later, uncomment to obey also 'anyNotes' when that's untangled return callback null, false if user.emails?.recruitNotes?.enabled is false return callback null, false if user.email in alreadyEmailed @@ -129,6 +130,7 @@ grabEmail = (winner, callback) -> callback null, winner emailUserTournamentResults = (winner, callback) -> + return callback null, false if not winner.email return callback null, false if DEBUGGING and (winner.team is 'humans' or totalEmailsSent > 1) ++totalEmailsSent name = winner.name diff --git a/server/commons/Handler.coffee b/server/commons/Handler.coffee index a4d50b9bc..906b3ec1c 100644 --- a/server/commons/Handler.coffee +++ b/server/commons/Handler.coffee @@ -82,6 +82,8 @@ module.exports = class Handler sendBadInputError: (res, message) -> errors.badInput(res, message) sendPaymentRequiredError: (res, message) -> errors.paymentRequired(res, message) sendDatabaseError: (res, err) -> + if err instanceof errors.NetworkError + return res.status(err.code).send(err.toJSON()) return @sendError(res, err.code, err.response) if err?.response and err?.code log.error "Database error, #{err}" errors.serverError(res, 'Database error, ' + err) @@ -467,6 +469,7 @@ module.exports = class Handler @notifyWatcherOfChange editor, watcher, changedDocument, editPath notifyWatcherOfChange: (editor, watcher, changedDocument, editPath) -> + return if not watcher.get('email') context = email_id: sendwithus.templates.change_made_notify_watcher recipient: diff --git a/server/commons/errors.coffee b/server/commons/errors.coffee index 24276645b..33499d713 100644 --- a/server/commons/errors.coffee +++ b/server/commons/errors.coffee @@ -100,7 +100,7 @@ errorResponseSchema = { } errorProps = _.keys(errorResponseSchema.properties) -class NetworkError +class NetworkError extends Error code: 0 constructor: (@message, options) -> diff --git a/server/handlers/patch_handler.coffee b/server/handlers/patch_handler.coffee index 5d0de52e8..3e1811cad 100644 --- a/server/handlers/patch_handler.coffee +++ b/server/handlers/patch_handler.coffee @@ -98,7 +98,8 @@ PatchHandler = class PatchHandler extends Handler @sendPatchCreatedEmail req.user, watcher, doc, doc.targetLoaded, docLink sendPatchCreatedEmail: (patchCreator, watcher, patch, target, docLink) -> -# return if watcher._id is patchCreator._id + return if not watcher.get('email') + # return if watcher._id is patchCreator._id context = email_id: sendwithus.templates.patch_created recipient: diff --git a/server/handlers/subscription_handler.coffee b/server/handlers/subscription_handler.coffee index 65760a82e..94a9a7574 100644 --- a/server/handlers/subscription_handler.coffee +++ b/server/handlers/subscription_handler.coffee @@ -270,6 +270,9 @@ class SubscriptionHandler extends Handler if (not req.user) or req.user.isAnonymous() or user.isAnonymous() return done({res: 'You must be signed in to subscribe.', code: 403}) + if not req.user.get('email') + return done({res: 'Your account needs an email address to subscribe.', code: 403}) + token = req.body.stripe.token prepaidCode = req.body.stripe.prepaidCode customerID = user.get('stripe')?.customerID diff --git a/server/handlers/user_handler.coffee b/server/handlers/user_handler.coffee index 6c99e561d..07445cd1d 100644 --- a/server/handlers/user_handler.coffee +++ b/server/handlers/user_handler.coffee @@ -110,9 +110,9 @@ UserHandler = class UserHandler extends Handler # Name setting (req, user, callback) -> - return callback(null, req, user) unless req.body.name + return callback(null, req, user) unless req.body.name? nameLower = req.body.name?.toLowerCase() - return callback(null, req, user) unless nameLower + return callback(null, req, user) unless nameLower? return callback(null, req, user) if user.get 'anonymous' # anonymous users can have any name return callback(null, req, user) if nameLower is user.get('nameLower') User.findOne({nameLower: nameLower, anonymous: false}).exec (err, otherUser) -> diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index 45f12436f..04c12fafc 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -89,6 +89,8 @@ module.exports = timestamp = (new Date).getTime() if not user throw new errors.NotFound('User not found') + if not user.get('email') + throw new errors.UnprocessableEntity('User must have an email address to receive a verification email') context = email_id: sendwithus.templates.verify_email recipient: @@ -127,14 +129,18 @@ module.exports = unless req.user.isAnonymous() throw new errors.Forbidden('You are already signed in.') - { password, email } = req.body - unless _.all([password, email]) - throw new errors.UnprocessableEntity('Requires password and email') + { name, email, password } = req.body + unless password + throw new errors.UnprocessableEntity('Requires password') + unless name or email + throw new errors.UnprocessableEntity('Requires username or email') - if yield User.findByEmail(email) + if not _.isEmpty(email) and yield User.findByEmail(email) throw new errors.Conflict('Email already taken') + if not _.isEmpty(name) and yield User.findByName(name) + throw new errors.Conflict('Name already taken') - req.user.set({ password, email, anonymous: false }) + req.user.set({ name, email, password, anonymous: false }) yield module.exports.finishSignup(req, res) signupWithFacebook: wrap (req, res) -> diff --git a/server/middleware/versions.coffee b/server/middleware/versions.coffee index 9c5e367f1..cafdf18ed 100644 --- a/server/middleware/versions.coffee +++ b/server/middleware/versions.coffee @@ -105,6 +105,7 @@ module.exports = if watchers.length User.find({_id:{$in:watchers}}).select({email:1, name:1}).exec (err, watchers) -> for watcher in watchers + continue if not watcher.get('email') context = email_id: sendwithus.templates.change_made_notify_watcher recipient: diff --git a/server/models/User.coffee b/server/models/User.coffee index 39da42657..8d9219777 100644 --- a/server/models/User.coffee +++ b/server/models/User.coffee @@ -122,6 +122,10 @@ UserSchema.statics.findByEmail = (email, done=_.noop) -> emailLower = email.toLowerCase() User.findOne({emailLower: emailLower}).exec(done) +UserSchema.statics.findByName = (name, done=_.noop) -> + nameLower = name.toLowerCase() + User.findOne({nameLower: nameLower}).exec(done) + emailNameMap = generalNews: 'announcement' adventurerNews: 'tester' @@ -267,6 +271,7 @@ UserSchema.statics.unconflictName = unconflictName = (name, done) -> unconflictName name + suffix, done UserSchema.methods.sendWelcomeEmail = -> + return if not @get('email') { welcome_email_student, welcome_email_user } = sendwithus.templates timestamp = (new Date).getTime() data = @@ -345,14 +350,25 @@ UserSchema.methods.saveActiveUser = (event, done=null) -> UserSchema.pre('save', (next) -> if _.isNaN(@get('purchased')?.gems) - return next(new errors.InternalServerError('Attempting to save NaN to user')) + return next(new errors.InternalServerError('Attempting to save NaN to user')) Classroom = require './Classroom' if @isTeacher() and not @wasTeacher Classroom.update({members: @_id}, {$pull: {members: @_id}}, {multi: true}).exec (err, res) -> + if email = @get('email') @set('emailLower', email.toLowerCase()) + else + @set('email', undefined) + @set('emailLower', undefined) if name = @get('name') @set('nameLower', name.toLowerCase()) + else + @set('name', undefined) + @set('nameLower', undefined) + + unless email or name or @get('anonymous') or @get('deleted') + return next(new errors.UnprocessableEntity('User needs a username or email address')) + pwd = @get('password') if @get('password') @set('passwordHash', User.hashPassword(pwd)) diff --git a/server/queues/scoring/createNewTask.coffee b/server/queues/scoring/createNewTask.coffee index 3d3bc39cc..67668504a 100644 --- a/server/queues/scoring/createNewTask.coffee +++ b/server/queues/scoring/createNewTask.coffee @@ -26,7 +26,7 @@ module.exports = createNewTask = (req, res) -> validatePermissions = (req, sessionID, callback) -> - return callback 'You are unauthorized to submit that game to the simulator.' unless req.user?.get('email') + return callback 'You are unauthorized to submit that game to the simulator.' if (not req.user) or req.user.isAnonymous() return callback null if req.user?.isAdmin() findParameters = _id: sessionID diff --git a/server/queues/scoring/dispatchTaskToConsumer.coffee b/server/queues/scoring/dispatchTaskToConsumer.coffee index b421c39d3..6acec171f 100644 --- a/server/queues/scoring/dispatchTaskToConsumer.coffee +++ b/server/queues/scoring/dispatchTaskToConsumer.coffee @@ -26,7 +26,7 @@ module.exports = dispatchTaskToConsumer = (req, res) -> checkSimulationPermissions = (req, cb) -> - if req.user?.get('email') + if req.user and not req.user.isAnonymous() cb null else cb 'You need to be logged in to simulate games' diff --git a/server/routes/mail.coffee b/server/routes/mail.coffee index b888f261e..4af2a20b0 100644 --- a/server/routes/mail.coffee +++ b/server/routes/mail.coffee @@ -471,6 +471,7 @@ taskReminderAlreadySentThisWeekFilter = (task, cb) -> sendUserRemarkTaskEmail = (task, cb) -> mailTaskName = @mailTaskName User.findOne("_id":task.contact).select("email").lean().exec (err, contact) -> + return if not contact.email if err? then return cb err User.findOne("_id":task.user).select("jobProfile.name").lean().exec (err, user) -> if err? then return cb err @@ -567,6 +568,7 @@ handleLadderUpdate = (req, res) -> sendLadderUpdateEmail = (session, now, daysAgo) -> User.findOne({_id: session.creator}).select('name email firstName lastName emailSubscriptions emails preferredLanguage').exec (err, user) -> + return if not user.get('email') if err log.error "Couldn't find user for #{session.creator} from session #{session._id}" return @@ -686,13 +688,14 @@ handleNextSteps = (req, res) -> log.info "Found #{results.length} next-steps users to email updates about for #{daysAgo} day(s) ago." if DEBUGGING sendNextStepsEmail result, now, daysAgo for result in results -sendNextStepsEmail = (user, now, daysAgo) -> +module.exports.sendNextStepsEmail = sendNextStepsEmail = (user, now, daysAgo) -> + return log.info "Not sending next steps email to user with no email address" if not user.get('email') unless user.isEmailSubscriptionEnabled('generalNews') and user.isEmailSubscriptionEnabled('anyNotes') log.info "Not sending email to #{user.get('email')} #{user.get('name')} because they only want emails about #{JSON.stringify(user.get('emails'))}" if DEBUGGING return LevelSession.find({creator: user.get('_id') + ''}).select('levelName levelID changed state.complete playtime').lean().exec (err, sessions) -> - return log.error "Couldn't find sessions for #{user.get('email')}: #{err}" if err + return log.error "Couldn't find sessions for #{user.get('email')} #{user.get('name')}: #{err}" if err complete = (s for s in sessions when s.state?.complete) incomplete = (s for s in sessions when not s.state?.complete) return if complete.length < 2 @@ -704,7 +707,7 @@ sendNextStepsEmail = (user, now, daysAgo) -> nextLevel = null err = null do (err, nextLevel) -> - return log.error "Couldn't find next level for #{user.get('email')}: #{err}" if err + return log.error "Couldn't find next level for #{user.get('email')} #{user.get('name')}: #{err}" if err name = if user.get('firstName') and user.get('lastName') then "#{user.get('firstName')}" else user.get('name') name = 'Hero' if not name or name in ['Anoner', 'Anonymous'] #secretLevel = switch user.get('testGroupNumber') % 8 diff --git a/spec/server/functional/mail.spec.coffee b/spec/server/functional/mail.spec.coffee index 141e8e05b..a66a3b77f 100644 --- a/spec/server/functional/mail.spec.coffee +++ b/spec/server/functional/mail.spec.coffee @@ -1,7 +1,10 @@ require '../common' +utils = require '../utils' mail = require '../../../server/routes/mail' +sendwithus = require '../../../server/sendwithus' User = require '../../../server/models/User' request = require '../request' +LevelSession = require '../../../server/models/LevelSession' testPost = data: @@ -37,3 +40,30 @@ describe 'handleUnsubscribe', -> expect(u.isEmailSubscriptionEnabled('ambassadorNews')).toBeFalsy() expect(u.isEmailSubscriptionEnabled('artisanNews')).toBeFalsy() done() + +# This can be re-enabled on demand to test it, but for some async reason this +# crashes jasmine soon afterward. +describe 'sendNextStepsEmail', -> + xit 'Sends the email', utils.wrap (done) -> + user = yield utils.initUser({generalNews: {enabled: true}, anyNotes: {enabled: true}}) + expect(user.id).toBeDefined() + yield new LevelSession({ + creator: user.id + permissions: simplePermissions + level: original: 'dungeon-arena' + state: complete: true + }).save() + yield new LevelSession({ + creator: user.id + permissions: simplePermissions + level: original: 'dungeon-arena-2' + state: complete: true + }).save() + + spyOn(sendwithus.api, 'send').and.callFake (options, cb) -> + expect(options.recipient.address).toBe(user.get('email')) + cb() + done() + + mail.sendNextStepsEmail(user, new Date, 5) + .pend('Breaks other tests — must be run alone') diff --git a/spec/server/functional/subscription.spec.coffee b/spec/server/functional/subscription.spec.coffee index 57c2b2c26..47941bbca 100644 --- a/spec/server/functional/subscription.spec.coffee +++ b/spec/server/functional/subscription.spec.coffee @@ -1,7 +1,8 @@ async = require 'async' config = require '../../../server_config' require '../common' -utils = require '../../../app/core/utils' # Must come after require /common +appUtils = require '../../../app/core/utils' # Must come after require /common +utils = require '../utils' mongoose = require 'mongoose' TRAVIS = process.env.COCO_TRAVIS_TEST nockUtils = require '../nock-utils' @@ -113,6 +114,13 @@ describe '/db/user, editing stripe property', -> request.put {uri: userURL, json: body, headers: headers}, (err, res, body) -> expect(res.statusCode).toBe 403 done() + + it 'denies username-only users trying to subscribe', utils.wrap (done) -> + user = yield utils.initUser({ email: undefined, }) + yield utils.loginUser(user) + [res, body] = yield request.putAsync(getURL("/db/user/#{user.id}"), { headers, json: { stripe: { planID: 'basic', token: '12345' } } }) + expect(res.statusCode).toBe(403) + done() #- shared data between tests joeData = null @@ -327,7 +335,7 @@ describe 'Subscriptions', -> return done() unless subscription? expect(subscription.plan.amount).toEqual(1) expect(subscription.customer).toEqual(sponsorCustomerID) - expect(subscription.quantity).toEqual(utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?)) + expect(subscription.quantity).toEqual(appUtils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?)) # Verify sponsor payment # May be greater than expected amount due to multiple subscribes and unsubscribes @@ -336,7 +344,7 @@ describe 'Subscriptions', -> recipient: mongoose.Types.ObjectId(sponsorUserID) "stripe.customerID": sponsorCustomerID "stripe.subscriptionID": sponsorStripe.sponsorSubscriptionID - expectedAmount = utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?) + expectedAmount = appUtils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?) Payment.find paymentQuery, (err, payments) -> expect(err).toBeNull() expect(payments).not.toBeNull() @@ -1192,7 +1200,7 @@ describe 'Subscriptions', -> for invoice in invoices.data line = invoice.lines.data[0] if line.type is 'invoiceitem' and line.proration - totalAmount = utils.getSponsoredSubsAmount(subPrice, 2, false) + totalAmount = appUtils.getSponsoredSubsAmount(subPrice, 2, false) expect(invoice.total).toBeLessThan(totalAmount) expect(invoice.total).toEqual(totalAmount - subPrice) Payment.findOne "stripe.invoiceID": invoice.id, (err, payment) -> diff --git a/spec/server/functional/user.spec.coffee b/spec/server/functional/user.spec.coffee index cc5123f48..de924318c 100644 --- a/spec/server/functional/user.spec.coffee +++ b/spec/server/functional/user.spec.coffee @@ -234,6 +234,14 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl expect(body.role).toBe('advisor') done() + it 'returns 422 if both email and name would be unset for a registered user', utils.wrap (done) -> + user = yield utils.initUser() + yield utils.loginUser(user) + [res, body] = yield request.putAsync { uri: getURL('/db/user/'+user.id), json: { email: '', name: '' }} + expect(body.code).toBe(422) + expect(body.message).toEqual('User needs a username or email address') + done() + describe 'PUT /db/user/-/become-student', -> beforeEach utils.wrap (done) -> @url = getURL('/db/user/-/become-student') @@ -697,6 +705,50 @@ describe 'POST /db/user/:handle/signup-with-password', -> expect(sendwithus.api.send).toHaveBeenCalled() done() + it 'signs up the user with just a name and password', utils.wrap (done) -> + user = yield utils.becomeAnonymous() + url = getURL("/db/user/#{user.id}/signup-with-password") + name = 'someusername' + json = { name, password: '12345' } + [res, body] = yield request.postAsync({url, json}) + expect(res.statusCode).toBe(200) + updatedUser = yield User.findById(user.id) + expect(updatedUser.get('name')).toBe(name) + expect(updatedUser.get('nameLower')).toBe(name.toLowerCase()) + expect(updatedUser.get('slug')).toBe(name.toLowerCase()) + expect(updatedUser.get('passwordHash')).toBeDefined() + expect(updatedUser.get('email')).toBeUndefined() + expect(updatedUser.get('emailLower')).toBeUndefined() + done() + + it 'signs up the user with a username, email, and password', utils.wrap (done) -> + user = yield utils.becomeAnonymous() + url = getURL("/db/user/#{user.id}/signup-with-password") + name = 'someusername' + email = 'user@example.com' + json = { name, email, password: '12345' } + [res, body] = yield request.postAsync({url, json}) + expect(res.statusCode).toBe(200) + updatedUser = yield User.findById(user.id) + expect(updatedUser.get('name')).toBe(name) + expect(updatedUser.get('nameLower')).toBe(name.toLowerCase()) + expect(updatedUser.get('slug')).toBe(name.toLowerCase()) + expect(updatedUser.get('email')).toBe(email) + expect(updatedUser.get('emailLower')).toBe(email.toLowerCase()) + expect(updatedUser.get('passwordHash')).toBeDefined() + done() + + it 'returns 422 if neither username or email were provided', utils.wrap (done) -> + user = yield utils.becomeAnonymous() + url = getURL("/db/user/#{user.id}/signup-with-password") + json = { password: '12345' } + [res, body] = yield request.postAsync({url, json}) + expect(res.statusCode).toBe(422) + updatedUser = yield User.findById(user.id) + expect(updatedUser.get('anonymous')).toBe(true) + expect(updatedUser.get('passwordHash')).toBeUndefined() + done() + it 'returns 409 if there is already a user with the given email', utils.wrap (done) -> email = 'some@email.com' initialUser = yield utils.initUser({email}) @@ -707,6 +759,17 @@ describe 'POST /db/user/:handle/signup-with-password', -> [res, body] = yield request.postAsync({url, json}) expect(res.statusCode).toBe(409) done() + + it 'returns 409 if there is already a user with the given username', utils.wrap (done) -> + name = 'someusername' + initialUser = yield utils.initUser({name}) + expect(initialUser.get('nameLower')).toBeDefined() + user = yield utils.becomeAnonymous() + url = getURL("/db/user/#{user.id}/signup-with-password") + json = { name, password: '12345' } + [res, body] = yield request.postAsync({url, json}) + expect(res.statusCode).toBe(409) + done() it 'disassociates the user from their trial request if the trial request email and signup email do not match', utils.wrap (done) -> user = yield utils.becomeAnonymous() @@ -739,7 +802,7 @@ describe 'POST /db/user/:handle/signup-with-facebook', -> facebookID = '12345' facebookEmail = 'some@email.com' - validFacebookResponse = new Promise((resolve) -> resolve({ + validFacebookResponse = new Promise((resolve) -> resolve({ id: facebookID, email: facebookEmail, first_name: 'Some', @@ -753,12 +816,12 @@ describe 'POST /db/user/:handle/signup-with-facebook', -> verified: true })) - invalidFacebookResponse = new Promise((resolve) -> resolve({ + invalidFacebookResponse = new Promise((resolve) -> resolve({ error: { message: 'Invalid OAuth access token.', type: 'OAuthException', code: 190, - fbtrace_id: 'EC4dEdeKHBH' + fbtrace_id: 'EC4dEdeKHBH' } })) diff --git a/spec/server/utils.coffee b/spec/server/utils.coffee index 6c9aa9a0c..c51f88752 100644 --- a/spec/server/utils.coffee +++ b/spec/server/utils.coffee @@ -36,7 +36,8 @@ module.exports = mw = options = {} options = _.extend({ permissions: [] - email: 'user'+_.uniqueId()+'@gmail.com' + name: 'Name Nameyname '+_.uniqueId() + email: 'user'+_.uniqueId()+'@example.com' password: 'password' anonymous: false }, options) @@ -49,7 +50,7 @@ module.exports = mw = done = options options = {} form = { - username: user.get('email') + username: user.get('email') or user.get('name') password: 'password' } (options.request or request).post mw.getURL('/auth/login'), { form: form }, (err, res) -> @@ -89,7 +90,7 @@ module.exports = mw = args = Array.from(arguments) [done, [data, sources]] = [args.pop(), args] - data = _.extend({}, { + data = _.extend({}, { name: _.uniqueId('Level ') permissions: [{target: mw.lastLogin.id, access: 'owner'}] }, data)