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: 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/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/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: "Горячие клавиши" 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/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/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 cd323fc39..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 @@ -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/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/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/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/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; } } 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/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/" 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/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/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) 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)