Improve student account recovery

This adds the ability to verify email addresses of a user, so we know they have access to the email address on their account.

Until a user has verified their email address, any teacher of a class they're in can reset their password for them via the Teacher Dashboard. When a user's email address is verified, a teacher may trigger a password recovery email to be sent to the student.

Verification links are valid forever, until the user changes the email address they have on file. They are created using a timestamp, with a sha256 of timestamp+salt+userID+email. Currently the hash value is rather long, could be shorter.

Squashed commit messages:

Add server endpoints for verifying email address

Add server endpoints for verifying email address (pt 2)

Add Server+Client endpoint for sending verification email

Add client view for verification links

Add Edit Student Modal for resetting passwords

Add specs for EditStudentModal

Tweak method name in EditStudentModal

Add edit student button to TeacherClassView

Fix up frontend for teacher password resetting

Add middleware for teacher password resetting

Improve button UX in EditStudentModal

Add JoinClassModal

Add welcome emails, use broad name

Use email without domain as fallback instead of full email

Fetch user on edit student modal open

Don't allow password reset if student email is verified

Set role to student on user signup with classCode

Tweak interface for joinClassModal

Add button to request verification email for yourself

Fix verify email template ID

Move text to en.coffee

Minor tweaks

Fix code review comments

Fix some tests, disable a broken one

Fix misc tests

Fix more tests

Refactor recovery email sending to auth

Fix overbroad sass

Add options to refactored recovery email function

Rename getByCode to fetchByCode

Fix error message

Fix up error handling in users middleware

Use .get instead of .toObject

Use findById

Fix more code review comments

Disable still-broken test
This commit is contained in:
phoenixeliot 2016-05-11 14:39:26 -07:00
parent dd08a8bd64
commit 8496343a02
28 changed files with 513 additions and 26 deletions

View file

@ -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')

View file

@ -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)

View file

@ -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: "<Enter Class 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"

View file

@ -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)

View file

@ -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"

View file

@ -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'})

View file

@ -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

View file

@ -0,0 +1,8 @@
#email-verified-view
.alert
display: flex
align-items: center
justify-content: center
.glyphicon
font-size: 20pt

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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')

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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({

View file

@ -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 }

View file

@ -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'

View file

@ -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) =>

View file

@ -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}))

View file

@ -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()

View file

@ -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',

View file

@ -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

View file

@ -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'

View file

@ -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()

View file

@ -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')