diff --git a/app/core/Router.coffee b/app/core/Router.coffee index 44bc54c6a..13c6de310 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -159,6 +159,7 @@ module.exports = class CocoRouter extends Backbone.Router 'test(/*subpath)': go('TestView') 'user/:slugOrID': go('user/MainUserView') + 'user/:userID/verify/:verificationCode': go('user/EmailVerifiedView') '*name/': 'removeTrailingSlash' '*name': go('NotFoundView') diff --git a/app/core/auth.coffee b/app/core/auth.coffee index 43092d5d6..79c9f8ade 100644 --- a/app/core/auth.coffee +++ b/app/core/auth.coffee @@ -60,6 +60,12 @@ module.exports.logoutUser = -> res = $.post('/auth/logout', {}, callback) res.fail(genericFailure) +module.exports.sendRecoveryEmail = (email, options={}) -> + options = _.merge(options, + {method: 'POST', url: '/auth/reset', data: { email }} + ) + $.ajax(options) + onSetVolume = (e) -> return if e.volume is me.get('volume') me.set('volume', e.volume) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 8b0e1f4c3..cce630adb 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -132,6 +132,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr help_suff: "and we'll get in touch!" modal: + cancel: "Cancel" close: "Close" okay: "Okay" @@ -1173,6 +1174,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr view_class: "view class" view_levels: "view levels" join_class: "Join A Class" + join_class_2: "Join class" ask_teacher_for_code: "Ask your teacher if you have a CodeCombat class code! If so, enter it below:" enter_c_code: "" join: "Join" @@ -1319,6 +1321,12 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr update_account_update_student: "Update to Student" update_account_not_sure: "Not sure which one to choose? Email" update_account_confirm_update_student: "Are you sure you want to update your account to a Student experience?\n\nYou will not be able to manage any classes that you have previously created or create new classes. Your previously created classes will be removed from CodeCombat and cannot be restored." + instructor: "Instructor: " + by_joining_1: "By joining " + by_joining_2: "will be able to help reset your password if you forget or lose it. You can also verify your email address so that you can reset the password yourself!" + sent_verification: "We've sent a verification email to:" + you_can_edit: "You can edit your email address in " + account_settings: "Account Settings" teacher: teacher_dashboard: "Teacher Dashboard" # Navbar @@ -1360,6 +1368,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr course_progress: "Course Progress" not_applicable: "N/A" edit: "edit" + edit_2: "Edit" remove: "remove" latest_completed: "Latest Completed" sort_by: "Sort by" @@ -1404,6 +1413,12 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr bulk_pricing_blurb: "Purchasing for more than 25 students? Contact us to discuss next steps." total_unenrolled: "Total unenrolled" export_student_progress: "Export Student Progress (CSV)" + send_email_to: "Send Recover Password Email to:" + verified_email_address: "verified email address" + email_sent: "Email sent" + send_recovery_email: "Send recovery email" + change_password: "Change Password" + changed: "Changed" classes: archmage_title: "Archmage" @@ -1704,6 +1719,15 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr card: "Card" status_unsubscribed_active: "You're not subscribed and won't be billed, but your account is still active for now." status_unsubscribed: "Get access to new levels, heroes, items, and bonus gems with a CodeCombat subscription!" + not_yet_verified: "Not yet verified." + resend_email: "Resend email" + email_sent: "Email sent! Check your inbox." + verifying_email: "Verifying your email address..." + successfully_verified: "You've successfully verified your email address!" + back_to_student_page: "Go back to student things" + back_to_teacher_page: "Go to My Classes" + back_to_game: "Go play some more levels!" + verify_error: "Something went wrong when verifying your email :(" account_invoices: amount: "Amount in US dollars" diff --git a/app/models/Classroom.coffee b/app/models/Classroom.coffee index d75d0ab47..4ab54a752 100644 --- a/app/models/Classroom.coffee +++ b/app/models/Classroom.coffee @@ -1,6 +1,7 @@ CocoModel = require './CocoModel' schema = require 'schemas/models/classroom.schema' utils = require 'core/utils' +User = require 'models/User' module.exports = class Classroom extends CocoModel @className: 'Classroom' @@ -10,6 +11,15 @@ module.exports = class Classroom extends CocoModel initialize: () -> @listenTo @, 'change:aceConfig', @capitalizeLanguageName super(arguments...) + + parse: (obj) -> + if obj._id + # It's just the classroom object + return obj + else + # It's a compound response with other stuff too + @owner = new User(obj.owner) + return obj.data capitalizeLanguageName: -> language = @get('aceConfig')?.language @@ -17,9 +27,19 @@ module.exports = class Classroom extends CocoModel joinWithCode: (code, opts) -> options = { - url: _.result(@, 'url') + '/~/members' + url: @urlRoot + '/~/members' type: 'POST' data: { code: code } + success: => @trigger 'join:success' + error: => @trigger 'join:error' + } + _.extend options, opts + @fetch(options) + + fetchByCode: (code, opts) -> + options = { + url: _.result(@, 'url') + data: { code: code, "with-owner": true } } _.extend options, opts @fetch(options) diff --git a/app/models/User.coffee b/app/models/User.coffee index 8f9933796..db57fcee1 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -23,8 +23,8 @@ module.exports = class User extends CocoModel return name if name name = _.filter([@get('firstName'), @get('lastName')]).join(' ') return name if name - email = @get('email') - return email if email + [emailName, emailDomain] = @get('email')?.split('@') or [] + return emailName if emailName return 'Anoner' getPhotoURL: (size=80, useJobProfilePhoto=false, useEmployerPageAvatar=false) -> @@ -35,6 +35,9 @@ module.exports = class User extends CocoModel return "#{photoURL}#{prefix}s=#{size}" if photoURL.search('http') isnt -1 # legacy return "/file/#{photoURL}#{prefix}s=#{size}" return "/db/user/#{@id}/avatar?s=#{size}&employerPageAvatar=#{useEmployerPageAvatar}" + + getRequestVerificationEmailURL: -> + @url() + "/request-verify-email" getSlugOrID: -> @get('slug') or @get('_id') @@ -210,7 +213,17 @@ module.exports = class User extends CocoModel isOnPremiumServer: -> me.get('country') in ['china', 'brazil'] - + + sendVerificationCode: (code) -> + $.ajax({ + method: 'POST' + url: "/db/user/#{@id}/verify/#{code}" + success: (attributes) => + this.set attributes + @trigger 'email-verify-success' + error: => + @trigger 'email-verify-error' + }) # Function meant for "me" diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 40b76ef01..2b600272e 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -50,6 +50,7 @@ visa = c.shortString _.extend UserSchema.properties, email: c.shortString({title: 'Email', format: 'email'}) + emailVerified: { type: 'boolean' } iosIdentifierForVendor: c.shortString({format: 'hidden'}) firstName: c.shortString({title: 'First Name'}) lastName: c.shortString({title: 'Last Name'}) diff --git a/app/styles/courses/teacher-class-view.sass b/app/styles/courses/teacher-class-view.sass index 4d83ab4c0..2f09e4805 100644 --- a/app/styles/courses/teacher-class-view.sass +++ b/app/styles/courses/teacher-class-view.sass @@ -157,7 +157,8 @@ .glyphicon color: $gray-light - .remove-student-link + .edit-student-link, .remove-student-link + display: inline-block color: $burgandy font-weight: bold text-decoration: underline diff --git a/app/styles/user/email-verified-view.sass b/app/styles/user/email-verified-view.sass new file mode 100644 index 000000000..c2fe5d11a --- /dev/null +++ b/app/styles/user/email-verified-view.sass @@ -0,0 +1,8 @@ +#email-verified-view + .alert + display: flex + align-items: center + justify-content: center + + .glyphicon + font-size: 20pt diff --git a/app/templates/account/account-settings-view.jade b/app/templates/account/account-settings-view.jade index adb7f45bc..315a5169b 100644 --- a/app/templates/account/account-settings-view.jade +++ b/app/templates/account/account-settings-view.jade @@ -17,7 +17,15 @@ else label.control-label(for="name", data-i18n="general.name") Name input#name-input.form-control(name="name", type="text", value="#{name}") .form-group - label.control-label(for="email", data-i18n="general.email") Email + label.control-label(for="email") + span(data-i18n="general.email") Email + unless me.get('emailVerified') + span.spl ( + span.spr(data-i18n="account.not_yet_verified") + a.resend-verification-email + span.resend-text(data-i18n="account.resend_email") + span.sent-text.hide(data-i18n="account.email_sent") + span ) input#email.form-control(name="email", type="text", value="#{email}") if !isProduction .form-group.checkbox diff --git a/app/templates/courses/join-class-modal.jade b/app/templates/courses/join-class-modal.jade new file mode 100644 index 000000000..99327b72e --- /dev/null +++ b/app/templates/courses/join-class-modal.jade @@ -0,0 +1,38 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + if view.classroom.loaded + .text-center + h3.modal-title + span(data-i18n="courses.join") + span.spr + span= view.classroom.get('name') + b.small-details + span.spr(data-i18n="courses.instructor") + span= view.classroom.owner.get('name') + +block modal-body-content + if view.classroom.loaded + p + span.spr(data-i18n="courses.by_joining_1") + span= view.classroom.get('name') + span.spr , + span= view.classroom.owner.get('name') + span.spr + span(data-i18n="courses.by_joining_2") + + unless me.get('emailVerified') + div.text-center.m-t-4 + div + b.small-details + span(data-i18n="courses.sent_verification") + .small= me.get('email') + .small + span.spr(data-i18n="courses.you_can_edit") + a(href="/account/settings") + span(data-i18n="courses.account_settings") + +block modal-footer-content + .text-center + button.join-class-btn.btn.btn-lg.btn-navy(data-i18n="courses.join_class_2") + button.btn.btn-lg(data-dismiss="modal", data-i18n="common.cancel") diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade index cfd455d5f..2b0990f45 100644 --- a/app/templates/courses/teacher-class-view.jade +++ b/app/templates/courses/teacher-class-view.jade @@ -220,9 +220,13 @@ mixin studentRow(student) //- td //- span.view-class-arrow.glyphicon.glyphicon-chevron-right td - a.remove-student-link.small.center-block.text-center.pull-right.m-r-2(data-student-id=student.id) - div.glyphicon.glyphicon-remove - div(data-i18n='teacher.remove') + .pull-right + a.edit-student-link.small.center-block.text-center.m-r-2(data-student-id=student.id) + div.glyphicon.glyphicon-remove + div(data-i18n='teacher.edit') + a.remove-student-link.small.center-block.text-center.m-r-2(data-student-id=student.id) + div.glyphicon.glyphicon-remove + div(data-i18n='teacher.remove') mixin enrollStudentButton(student) a.enroll-student-button.btn.btn-lg.btn-primary(data-classroom-id=view.classroom.id data-user-id=student.id) diff --git a/app/templates/teachers/edit-student-modal.jade b/app/templates/teachers/edit-student-modal.jade new file mode 100644 index 000000000..627557573 --- /dev/null +++ b/app/templates/teachers/edit-student-modal.jade @@ -0,0 +1,42 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + h1 + span.spr(data-i18n="teacher.edit_2") + span=view.user.broadName() + +block modal-body-content + .text-center + if view.user.get('emailVerified') + div.m-b-1 + span(data-i18n="teacher.send_email_to") + div + = view.user.get('email') + div.small + span(data-i18n="teacher.verified_email_address") + if state.get('emailSent') + .send-recovery-email-btn.btn.btn-lg.btn-primary.uppercase.disabled + span(data-i18n="teacher.email_sent") + else + .send-recovery-email-btn.btn.btn-lg.btn-primary.uppercase + span(data-i18n="teacher.send_recovery_email") + else + div.m-b-1 + span(data-i18n="teacher.change_password") + span : + div + input.new-password-input(placeholder="type a new password here" value=state.get('newPassword')) + if state.get('passwordChanged') + button.change-password-btn.btn.btn-lg.btn-primary.uppercase.disabled + span(data-i18n="teacher.changed") + else + button.change-password-btn.btn.btn-lg.btn-primary.uppercase + span(data-i18n="teacher.change_password") + + +block modal-footer-content + button.btn.btn-primary(type="button", data-dismiss="modal", aria-hidden="true") + if state.get('passwordChanged') || state.get('emailSent') + span(data-i18n="modal.close") + else + span(data-i18n="modal.cancel") diff --git a/app/templates/user/email-verified-view.jade b/app/templates/user/email-verified-view.jade new file mode 100644 index 000000000..0a69791fb --- /dev/null +++ b/app/templates/user/email-verified-view.jade @@ -0,0 +1,34 @@ +extends /templates/base-flat + +block content + .container.text-center + if state.get('verifyStatus') === "pending" + span(data-i18n="account.verifying_email") + | Verifying your email address... + else if state.get('verifyStatus') === "success" + .alert.alert-success.center-block + .glyphicon.glyphicon-ok-circle.m-r-1 + span(data-i18n="account.successfully_verified") + | You've successfully verified your email address! + if view.user.isStudent() + a.btn.btn-lg.btn-navy(href="/courses") + span(data-i18n="account.back_to_student_page") + | Go back to student things + else if view.user.isTeacher() + a.btn.btn-lg.btn-navy(href="/teacher/classes") + span(data-i18n="account.back_to_teacher_page") + | Go to My Classes + else + a.btn.btn-lg.btn-navy(href="/play") + span(data-i18n="account.back_to_game") + | Go play some more levels! + else if state.get('verifyStatus') === "error" + .alert.alert-danger.center-block + .glyphicon.glyphicon-remove-circle.m-r-1 + span(data-i18n="account.verify_error") + | Something went wrong when verifying your email :( + else + div + | This really shouldn't happen + div + = state.get('verifyStatus') diff --git a/app/views/account/AccountSettingsView.coffee b/app/views/account/AccountSettingsView.coffee index d6a1a6eca..4a5d5dbbb 100644 --- a/app/views/account/AccountSettingsView.coffee +++ b/app/views/account/AccountSettingsView.coffee @@ -19,6 +19,7 @@ module.exports = class AccountSettingsView extends CocoView 'click #profile-photo-panel-body': 'onClickProfilePhotoPanelBody' 'click #delete-account-btn': 'onClickDeleteAccountButton' 'click #reset-progress-btn': 'onClickResetProgressButton' + 'click .resend-verification-email': 'onClickResendVerificationEmail' constructor: (options) -> super options @@ -83,6 +84,12 @@ module.exports = class AccountSettingsView extends CocoView confirmModal.on 'confirm', @resetProgress @openModalView confirmModal + onClickResendVerificationEmail: (e) -> + $.post me.getRequestVerificationEmailURL(), -> + link = $(e.currentTarget) + link.find('.resend-text').addClass('hide') + link.find('.sent-text').removeClass('hide') + validateCredentialsForDestruction: ($form, onSuccess) -> forms.clearFormAlerts($form) enteredEmail = $form.find('input[type="email"]').val() diff --git a/app/views/core/CreateAccountModal.coffee b/app/views/core/CreateAccountModal.coffee index 917b6e6d1..1fc210d16 100644 --- a/app/views/core/CreateAccountModal.coffee +++ b/app/views/core/CreateAccountModal.coffee @@ -61,7 +61,7 @@ module.exports = class CreateAccountModal extends ModalView error = false birthday = new Date Date.UTC attrs.birthdayYear, attrs.birthdayMonth - 1, attrs.birthdayDay if @classCode - #PASS + attrs.role = 'student' else if isNaN(birthday.getTime()) forms.setErrorToProperty @$el, 'birthdayDay', 'Required' error = true diff --git a/app/views/courses/CoursesView.coffee b/app/views/courses/CoursesView.coffee index a68d8b481..c581b7afe 100644 --- a/app/views/courses/CoursesView.coffee +++ b/app/views/courses/CoursesView.coffee @@ -5,6 +5,7 @@ AuthModal = require 'views/core/AuthModal' CreateAccountModal = require 'views/core/CreateAccountModal' ChangeCourseLanguageModal = require 'views/courses/ChangeCourseLanguageModal' ChooseLanguageModal = require 'views/courses/ChooseLanguageModal' +JoinClassModal = require 'views/courses/JoinClassModal' CourseInstance = require 'models/CourseInstance' CocoCollection = require 'collections/CocoCollection' Course = require 'models/Course' @@ -91,10 +92,16 @@ module.exports = class CoursesView extends RootView @renderSelectors '#join-class-form' return @renderSelectors '#join-class-form' - newClassroom = new Classroom() - newClassroom.joinWithCode(@classCode) - newClassroom.on 'sync', @onJoinClassroomSuccess, @ - newClassroom.on 'error', @onJoinClassroomError, @ + if me.get('emailVerified') + newClassroom = new Classroom() + jqxhr = newClassroom.joinWithCode(@classCode) + @listenTo newClassroom, 'join:success', -> @onJoinClassroomSuccess(newClassroom) + @listenTo newClassroom, 'join:error', -> @onJoinClassroomError(newClassroom, jqxhr) + else + modal = new JoinClassModal({ @classCode }) + @openModalView modal + @listenTo modal, 'join:success', @onJoinClassroomSuccess + @listenTo modal, 'join:error', @onJoinClassroomError onJoinClassroomError: (classroom, jqxhr, options) -> @state = null @@ -108,6 +115,7 @@ module.exports = class CoursesView extends RootView @renderSelectors '#join-class-form' onJoinClassroomSuccess: (newClassroom, data, options) -> + @state = null application.tracker?.trackEvent 'Joined classroom', { category: 'Courses' classCode: @classCode diff --git a/app/views/courses/JoinClassModal.coffee b/app/views/courses/JoinClassModal.coffee new file mode 100644 index 000000000..78b3ad161 --- /dev/null +++ b/app/views/courses/JoinClassModal.coffee @@ -0,0 +1,28 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/courses/join-class-modal' +Classroom = require 'models/Classroom' +User = require 'models/User' + +module.exports = class JoinClassModal extends ModalView + id: 'join-class-modal' + template: template + + events: + 'click .join-class-btn': 'onClickJoinClassButton' + + initialize: ({ @classCode }) -> + @classroom = new Classroom() + @teacher = new User() + jqxhr = @supermodel.trackRequest @classroom.fetchByCode(@classCode) + unless me.get('emailVerified') + @supermodel.trackRequest $.post("/db/user/#{me.id}/request-verify-email") + @listenTo @classroom, 'sync', -> + @render + @listenTo @classroom, 'join:success', -> + @trigger('join:success', @classroom) + @listenTo @classroom, 'join:error', -> + @trigger('join:error', @classroom, jqxhr) + # @close() + + onClickJoinClassButton: -> + @classroom.joinWithCode(@classCode) diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index 5302d0ee2..18a58fcdf 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -5,6 +5,7 @@ helper = require 'lib/coursesHelper' ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal' InviteToClassroomModal = require 'views/courses/InviteToClassroomModal' ActivateLicensesModal = require 'views/courses/ActivateLicensesModal' +EditStudentModal = require 'views/teachers/EditStudentModal' RemoveStudentModal = require 'views/courses/RemoveStudentModal' Classroom = require 'models/Classroom' @@ -36,6 +37,7 @@ module.exports = class TeacherClassView extends RootView 'click .sort-by-progress': 'sortByProgress' 'click #copy-url-btn': 'copyURL' 'click #copy-code-btn': 'copyCode' + 'click .edit-student-link': 'onClickEditStudentLink' 'click .remove-student-link': 'onClickRemoveStudentLink' 'click .assign-student-button': 'onClickAssign' 'click .enroll-student-button': 'onClickEnroll' @@ -220,6 +222,11 @@ module.exports = class TeacherClassView extends RootView @openModalView(modal) @listenToOnce modal, 'hide', @render + onClickEditStudentLink: (e) -> + user = @students.get($(e.currentTarget).data('student-id')) + modal = new EditStudentModal({ user }) + @openModalView(modal) + onClickRemoveStudentLink: (e) -> user = @students.get($(e.currentTarget).data('student-id')) modal = new RemoveStudentModal({ diff --git a/app/views/teachers/EditStudentModal.coffee b/app/views/teachers/EditStudentModal.coffee new file mode 100644 index 000000000..7d5c3dfb2 --- /dev/null +++ b/app/views/teachers/EditStudentModal.coffee @@ -0,0 +1,40 @@ +ModalView = require 'views/core/ModalView' +State = require 'models/State' +template = require 'templates/teachers/edit-student-modal' +auth = require 'core/auth' + +module.exports = class EditStudentModal extends ModalView + id: 'edit-student-modal' + template: template + + events: + 'click .send-recovery-email-btn:not(.disabled)': 'onClickSendRecoveryEmail' + 'click .change-password-btn:not(.disabled)': 'onClickChangePassword' + 'input .new-password-input': 'onChangeNewPasswordInput' + + initialize: ({ @user }) -> + @supermodel.trackRequest @user.fetch() + @utils = require 'core/utils' + @state = new State({ + emailSent: false + passwordChanged: false + newPassword: "" + }) + @listenTo @state, 'change', @render + + onClickSendRecoveryEmail: -> + email = @user.get('email') + auth.sendRecoveryEmail(email).then => + @state.set { emailSent: true } + + onClickChangePassword: -> + @user.set({ password: @state.get('newPassword') }) + @user.save() + @user.unset('password') + @listenToOnce @user, 'save:success', -> + @state.set { passwordChanged: true } + @listenTo @user, 'invalid', -> + # TODO: Show an error. (password too short) + + onChangeNewPasswordInput: (e) -> + @state.set { newPassword: $(e.currentTarget).val() }, { silent: true } diff --git a/app/views/user/EmailVerifiedView.coffee b/app/views/user/EmailVerifiedView.coffee new file mode 100644 index 000000000..c7ca8367f --- /dev/null +++ b/app/views/user/EmailVerifiedView.coffee @@ -0,0 +1,23 @@ +RootView = require 'views/core/RootView' +State = require 'models/State' +template = require 'templates/user/email-verified-view' +User = require 'models/User' + +module.exports = class EmailVerifiedView extends RootView + id: 'email-verified-view' + template: template + + initialize: (options, @userID, @verificationCode) -> + super(options) + @state = new State(@getInitialState()) + @user = new User({ _id: @userID }) + @user.sendVerificationCode(@verificationCode) + + @listenTo @state, 'change', @render + @listenTo @user, 'email-verify-success', -> + @state.set { verifyStatus: 'success' } + @listenTo @user, 'email-verify-error', -> + @state.set { verifyStatus: 'error' } + + getInitialState: -> + verifyStatus: 'pending' diff --git a/server/handlers/user_handler.coffee b/server/handlers/user_handler.coffee index 4bf97d94d..431e6dc93 100644 --- a/server/handlers/user_handler.coffee +++ b/server/handlers/user_handler.coffee @@ -43,8 +43,8 @@ UserHandler = class UserHandler extends Handler props.push 'jobProfileApproved', 'jobProfileNotes','jobProfileApprovedDate' if req.user.isAdmin() # Admins naturally edit these props.push @privateProperties... if req.user.isAdmin() # Admins are mad with power if not req.user.isAdmin() - if document.isTeacher() and req.body.role not in User.teacherRoles - props = _.without props, 'role' + if document.isTeacher() and req.body.role not in User.teacherRoles + props = _.without props, 'role' props formatEntity: (req, document, publicOnly=false) => diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee index a106ea528..505a8695b 100644 --- a/server/middleware/classrooms.coffee +++ b/server/middleware/classrooms.coffee @@ -15,6 +15,17 @@ User = require '../models/User' CourseInstance = require '../models/CourseInstance' module.exports = + fetchByCode: wrap (req, res, next) -> + code = req.query.code + return next() unless code + classroom = yield Classroom.findOne({ code: code.toLowerCase() }).select('name ownerID') + if not classroom + res.status(404).send({}) + classroom = classroom.toObject() + # Tack on the teacher's name for display to the user + owner = (yield User.findOne({ _id: mongoose.Types.ObjectId(classroom.ownerID) }).select('name')).toObject() + res.status(200).send({ data: classroom, owner } ) + getByOwner: wrap (req, res, next) -> options = req.query ownerID = options.ownerID @@ -151,7 +162,7 @@ module.exports = code = req.body.code.toLowerCase() classroom = yield Classroom.findOne({code: code}) if not classroom - throw new errors.NotFound(res) + throw new errors.NotFound('Classroom not found.') members = _.clone(classroom.get('members')) if _.any(members, (memberID) -> memberID.equals(req.user._id)) return res.send(classroom.toObject({req: req})) diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index 7a7505920..190f8559f 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -6,6 +6,7 @@ Promise = require 'bluebird' parse = require '../commons/parse' request = require 'request' mongoose = require 'mongoose' +sendwithus = require '../sendwithus' User = require '../models/User' Classroom = require '../models/Classroom' @@ -57,3 +58,55 @@ module.exports = yield User.update({ _id: userID }, { $set: { "role": "student" } }) user = yield User.findById req.user.id res.status(200).send(user.toObject({req: req})) + + verifyEmailAddress: wrap (req, res, next) -> + user = yield User.findOne({ _id: mongoose.Types.ObjectId(req.params.userID) }) + [timestamp, hash] = req.params.verificationCode.split(':') + unless user + throw new errors.UnprocessableEntity('User not found') + unless req.params.verificationCode is user.verificationCode(timestamp) + throw new errors.UnprocessableEntity('Verification code does not match') + yield User.update({ _id: user.id }, { emailVerified: true }) + res.status(200).send({ role: user.get('role') }) + + resetEmailVerifiedFlag: wrap (req, res, next) -> + newEmail = req.body.email + _id = mongoose.Types.ObjectId(req.body._id) + if newEmail + user = yield User.findOne({ _id }) + oldEmail = user.get('email') + if newEmail isnt oldEmail + yield User.update({ _id }, { $set: { emailVerified: false } }) + next() + + sendVerificationEmail: wrap (req, res, next) -> + user = yield User.findById(req.params.userID) + timestamp = (new Date).getTime() + if not user + throw new errors.NotFound('User not found') + context = + email_id: sendwithus.templates.verify_email + recipient: + address: user.get('email') + name: user.broadName() + email_data: + name: user.broadName() + verify_link: "http://codecombat.com/user/#{user._id}/verify/#{user.verificationCode(timestamp)}" + sendwithus.api.send context, (err, result) -> + res.status(200).send({}) + + teacherPasswordReset: wrap (req, res, next) -> + ownedClassrooms = yield Classroom.find({ ownerID: mongoose.Types.ObjectId(req.user.id) }) + ownedStudentIDs = _.flatten ownedClassrooms.map (c) -> + c.get('members').map (id) -> + id.toString() + newPassword = req.body.password + studentID = req.params.handle + student = yield User.findById(studentID) + if student.get('emailVerified') + return next new errors.Forbidden("Can't reset password for a student that has verified their email address.") + if newPassword and studentID in ownedStudentIDs + yield student.update({ $set: { passwordHash: User.hashPassword(newPassword) } }) + res.status(200).send({}) + else + next() diff --git a/server/models/User.coffee b/server/models/User.coffee index ed42fc5ed..9ad00363f 100644 --- a/server/models/User.coffee +++ b/server/models/User.coffee @@ -8,6 +8,7 @@ plugins = require '../plugins/plugins' AnalyticsUsersActive = require './AnalyticsUsersActive' Classroom = require '../models/Classroom' languages = require '../routes/languages' +_ = require 'lodash' config = require '../../server_config' stripe = require('stripe')(config.stripe.secretKey) @@ -39,6 +40,16 @@ UserSchema.post('init', -> @set('anonymous', false) if @get('email') ) +UserSchema.methods.broadName = -> + return '(deleted)' if @get('deleted') + name = @get('name') + return name if name + name = _.filter([@get('firstName'), @get('lastName')]).join(' ') + return name if name + [emailName, emailDomain] = @get('email').split('@') + return emailName if emailName + return 'Anoner' + UserSchema.methods.isInGodMode = -> p = @get('permissions') return p and 'godmode' in p @@ -67,6 +78,9 @@ UserSchema.statics.teacherRoles = ['teacher', 'technology coordinator', 'advisor UserSchema.methods.isTeacher = -> return @get('role') in User.teacherRoles +UserSchema.methods.isStudent = -> + return @get('role') is 'student' + UserSchema.methods.getUserInfo = -> id: @get('_id') email: if @get('anonymous') then 'Unregistered User' else @get('email') @@ -242,13 +256,18 @@ UserSchema.methods.register = (done) -> @set 'name', uniqueName done() else done() - if @isEmailSubscriptionEnabled 'generalNews' - data = - email_id: sendwithus.templates.welcome_email - recipient: - address: @get 'email' - sendwithus.api.send data, (err, result) -> - log.error "sendwithus post-save error: #{err}, result: #{result}" if err + { welcome_email_student, welcome_email_user } = sendwithus.templates + timestamp = (new Date).getTime() + data = + email_id: if @isStudent() then welcome_email_student else welcome_email_user + recipient: + address: @get('email') + name: @broadName() + email_data: + name: @broadName() + verify_link: "http://codecombat.com/user/#{@_id}/verify/#{@verificationCode(timestamp)}" + sendwithus.api.send data, (err, result) -> + log.error "sendwithus post-save error: #{err}, result: #{result}" if err @saveActiveUser 'register' UserSchema.methods.hasSubscription = -> @@ -341,6 +360,12 @@ UserSchema.statics.hashPassword = (password) -> shasum.update(salt + password) shasum.digest('hex') +UserSchema.methods.verificationCode = (timestamp) -> + { _id, email } = this.toObject() + shasum = crypto.createHash('sha256') + hash = shasum.update(timestamp + salt + _id + email).digest('hex') + return "#{timestamp}:#{hash}" + UserSchema.statics.privateProperties = [ 'permissions', 'email', 'mailChimp', 'firstName', 'lastName', 'gender', 'facebookID', 'gplusID', 'music', 'volume', 'aceConfig', 'employerAt', 'signedEmployerAgreement', diff --git a/server/routes/index.coffee b/server/routes/index.coffee index ba320a281..83dc5ebae 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -55,7 +55,7 @@ module.exports.setup = (app) -> app.get('/db/campaign/-/overworld', mw.campaigns.fetchOverworld) app.post('/db/classroom', mw.classrooms.post) - app.get('/db/classroom', mw.classrooms.getByOwner) + app.get('/db/classroom', mw.classrooms.fetchByCode, mw.classrooms.getByOwner) app.get('/db/classroom/:handle/levels', mw.classrooms.fetchAllLevels) app.get('/db/classroom/:handle/courses/:courseID/levels', mw.classrooms.fetchLevelsForCourse) app.get('/db/classroom/:handle/member-sessions', mw.classrooms.fetchMemberSessions) @@ -76,10 +76,14 @@ module.exports.setup = (app) -> app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers) app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom) + app.put('/db/user/:handle', mw.users.resetEmailVerifiedFlag, mw.users.teacherPasswordReset) + app.patch('/db/user/:handle', mw.users.resetEmailVerifiedFlag, mw.users.teacherPasswordReset) app.delete('/db/user/:handle', mw.users.removeFromClassrooms) app.get('/db/user', mw.users.fetchByGPlusID, mw.users.fetchByFacebookID) app.put('/db/user/-/become-student', mw.users.becomeStudent) app.put('/db/user/-/remain-teacher', mw.users.remainTeacher) + app.post('/db/user/:userID/request-verify-email', mw.users.sendVerificationEmail) + app.post('/db/user/:userID/verify/:verificationCode', mw.users.verifyEmailAddress) # TODO: Finalize URL scheme app.get '/db/products', require('./db/product').get diff --git a/server/sendwithus.coffee b/server/sendwithus.coffee index 90fcde935..6e781beda 100644 --- a/server/sendwithus.coffee +++ b/server/sendwithus.coffee @@ -18,7 +18,9 @@ if swuAPIKey module.exports.templates = parent_subscribe_email: 'tem_2APERafogvwKhmcnouigud' share_progress_email: 'tem_VHE3ihhGmVa3727qds9zY8' - welcome_email: 'utnGaBHuSU4Hmsi7qrAypU' + welcome_email_user: 'tem_z7Xvj3mtWYk6ec6aW7RwFk' + welcome_email_student: 'tem_4WYPZNLzs5wawMF9qUJXUH' + verify_email: 'tem_zJee6uRsRmzqzktzneCkCn' ladder_update_email: 'JzaZxf39A4cKMxpPZUfWy4' patch_created: 'tem_xhxuNosLALsizTNojBjNcL' change_made_notify_watcher: 'tem_7KVkfmv9SZETb25dtHbUtG' diff --git a/spec/server/unit/user.spec.coffee b/spec/server/unit/user.spec.coffee index 2651ef591..84b28eeb6 100644 --- a/spec/server/unit/user.spec.coffee +++ b/spec/server/unit/user.spec.coffee @@ -55,3 +55,13 @@ describe 'User', -> classicUser.set('permissions', ['user']) expect(classicUser.isAdmin()).toBeFalsy() done() + + describe '.verificationCode(timestamp)', -> + it 'returns a timestamp and a hash', (done) -> + user = new User() + now = new Date() + code = user.verificationCode(now.getTime()) + expect(code).toMatch(/[0-9]{13}:[0-9a-f]{64}/) + [timestamp, hash] = code.split(':') + expect(new Date(parseInt(timestamp))).toEqual(now) + done() diff --git a/test/app/views/teachers/EditStudentModal.spec.coffee b/test/app/views/teachers/EditStudentModal.spec.coffee new file mode 100644 index 000000000..cb268911a --- /dev/null +++ b/test/app/views/teachers/EditStudentModal.spec.coffee @@ -0,0 +1,69 @@ +EditStudentModal = require 'views/teachers/EditStudentModal' +User = require 'models/User' +factories = require 'test/app/factories' + +describe 'EditStudentModal', -> + + user = null + modal = null + email = "test@example.com" + newPassword = "new password" + + describe 'for a verified user', -> + beforeEach (done) -> + user = factories.makeUser({ email, emailVerified: true }) + modal = new EditStudentModal({ user }) + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ status: 200, responseText: JSON.stringify(user) }) + jasmine.demoModal(modal) + modal.render() + _.defer done + + it 'has a button to send a password reset email', -> + if modal.$('.send-recovery-email-btn').length < 1 + fail "Expected there to be a Send Recovery Email button" + + it 'sends the verification email request', -> + modal.$('.send-recovery-email-btn').click() + request = jasmine.Ajax.requests.mostRecent() + expect(request.params).toEqual("email=#{encodeURIComponent(email)}") + + it 'updates the button after the request is sent', -> + modal.$('.send-recovery-email-btn').click() + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ status: 200, responseText: "{}" }) + expect(modal.$('.send-recovery-email-btn [data-i18n]').data('i18n')).toEqual('teacher.email_sent') + + describe 'for an unverified user', -> + beforeEach (done) -> + user = factories.makeUser({ email , emailVerified: false }) + modal = new EditStudentModal({ user }) + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ status: 200, responseText: JSON.stringify(user) }) + jasmine.demoModal(modal) + modal.render() + _.defer done + + it "has a new password field", -> + if modal.$('.new-password-input').length < 1 + fail "Expected there to be a new password input field" + + it "has a change password button", -> + if modal.$('.change-password-btn').length < 1 + fail "Expected there to be a Change Password button" + + describe 'when you click the button', -> + it 'sends a request', -> + modal.$('.change-password-btn').click() + request = jasmine.Ajax.requests.mostRecent() + expect(request).toBeDefined() + + xit 'updates the button', -> + request1 = jasmine.Ajax.requests.mostRecent() + fail "Expected a request to be sent" unless request1 + modal.$('.new-password-input').val(newPassword).change().trigger('input') + modal.$('.change-password-btn').click() + request2 = jasmine.Ajax.requests.mostRecent() + expect(request1).not.toBe(request2) + request1?.respondWith({ status: 200, responseText: JSON.stringify(user) }) + expect(modal.$('.change-password-btn [data-i18n]').data('i18n')).toEqual('teacher.changed')