mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-27 09:35:39 -05:00
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:
parent
dd08a8bd64
commit
8496343a02
28 changed files with 513 additions and 26 deletions
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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'})
|
||||
|
|
|
@ -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
|
||||
|
|
8
app/styles/user/email-verified-view.sass
Normal file
8
app/styles/user/email-verified-view.sass
Normal file
|
@ -0,0 +1,8 @@
|
|||
#email-verified-view
|
||||
.alert
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
.glyphicon
|
||||
font-size: 20pt
|
|
@ -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
|
||||
|
|
38
app/templates/courses/join-class-modal.jade
Normal file
38
app/templates/courses/join-class-modal.jade
Normal 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")
|
|
@ -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)
|
||||
|
|
42
app/templates/teachers/edit-student-modal.jade
Normal file
42
app/templates/teachers/edit-student-modal.jade
Normal 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")
|
34
app/templates/user/email-verified-view.jade
Normal file
34
app/templates/user/email-verified-view.jade
Normal 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')
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
28
app/views/courses/JoinClassModal.coffee
Normal file
28
app/views/courses/JoinClassModal.coffee
Normal 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)
|
|
@ -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({
|
||||
|
|
40
app/views/teachers/EditStudentModal.coffee
Normal file
40
app/views/teachers/EditStudentModal.coffee
Normal 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 }
|
23
app/views/user/EmailVerifiedView.coffee
Normal file
23
app/views/user/EmailVerifiedView.coffee
Normal 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'
|
|
@ -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) =>
|
||||
|
|
|
@ -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}))
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()
|
||||
|
|
69
test/app/views/teachers/EditStudentModal.spec.coffee
Normal file
69
test/app/views/teachers/EditStudentModal.spec.coffee
Normal 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')
|
Loading…
Reference in a new issue