mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-28 10:06:08 -05:00
8496343a02
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
282 lines
10 KiB
CoffeeScript
282 lines
10 KiB
CoffeeScript
CocoView = require 'views/core/CocoView'
|
|
template = require 'templates/account/account-settings-view'
|
|
{me} = require 'core/auth'
|
|
forms = require 'core/forms'
|
|
User = require 'models/User'
|
|
CreateAccountModal = require 'views/core/CreateAccountModal'
|
|
ConfirmModal = require 'views/editor/modal/ConfirmModal'
|
|
{logoutUser, me} = require('core/auth')
|
|
|
|
module.exports = class AccountSettingsView extends CocoView
|
|
id: 'account-settings-view'
|
|
template: template
|
|
className: 'countainer-fluid'
|
|
|
|
events:
|
|
'change .panel input': 'onChangePanelInput'
|
|
'change #name-input': 'onChangeNameInput'
|
|
'click #toggle-all-btn': 'onClickToggleAllButton'
|
|
'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
|
|
require('core/services/filepicker')() unless window.application.isIPadApp # Initialize if needed
|
|
@uploadFilePath = "db/user/#{me.id}"
|
|
|
|
afterInsert: ->
|
|
super()
|
|
@openModalView new CreateAccountModal() if me.get('anonymous')
|
|
|
|
getEmailSubsDict: ->
|
|
subs = {}
|
|
return subs unless me
|
|
subs[sub] = 1 for sub in me.getEnabledEmails()
|
|
return subs
|
|
|
|
#- Form input callbacks
|
|
onChangePanelInput: (e) ->
|
|
return if $(e.target).closest('.form').attr('id') in ['reset-progress-form', 'delete-account-form']
|
|
$(e.target).addClass 'changed'
|
|
@trigger 'input-changed'
|
|
|
|
onClickToggleAllButton: ->
|
|
subs = @getSubscriptions()
|
|
$('#email-panel input[type="checkbox"]', @$el).prop('checked', not _.any(_.values(subs))).addClass('changed')
|
|
@trigger 'input-changed'
|
|
|
|
onChangeNameInput: ->
|
|
name = $('#name-input', @$el).val()
|
|
return if name is me.get 'name'
|
|
User.getUnconflictedName name, (newName) =>
|
|
forms.clearFormAlerts(@$el)
|
|
if name is newName
|
|
@suggestedName = undefined
|
|
else
|
|
@suggestedName = newName
|
|
forms.setErrorToProperty @$el, 'name', "That name is taken! How about #{newName}?", true
|
|
|
|
onPictureChanged: (e) =>
|
|
@trigger 'inputChanged', e
|
|
@$el.find('.gravatar-fallback').toggle not me.get 'photoURL'
|
|
|
|
onClickDeleteAccountButton: (e) ->
|
|
@validateCredentialsForDestruction @$el.find('#delete-account-form'), =>
|
|
renderData =
|
|
title: 'Are you really sure?'
|
|
body: 'This will completely delete your account. This action CANNOT be undone. Are you entirely sure?'
|
|
decline: 'Cancel'
|
|
confirm: 'DELETE Your Account'
|
|
confirmModal = new ConfirmModal renderData
|
|
confirmModal.on 'confirm', @deleteAccount
|
|
@openModalView confirmModal
|
|
|
|
onClickResetProgressButton: ->
|
|
@validateCredentialsForDestruction @$el.find('#reset-progress-form'), =>
|
|
renderData =
|
|
title: 'Are you really sure?'
|
|
body: 'This will completely erase your progress: code, levels, achievements, earned gems, and course work. This action CANNOT be undone. Are you entirely sure?'
|
|
decline: 'Cancel'
|
|
confirm: 'Erase ALL Progress'
|
|
confirmModal = new ConfirmModal renderData
|
|
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()
|
|
enteredPassword = $form.find('input[type="password"]').val()
|
|
if enteredEmail and enteredEmail is me.get('email')
|
|
isPasswordCorrect = false
|
|
toBeDelayed = true
|
|
$.ajax
|
|
url: '/auth/login'
|
|
type: 'POST'
|
|
data:
|
|
username: enteredEmail
|
|
password: enteredPassword
|
|
parse: true
|
|
error: (error) ->
|
|
toBeDelayed = false
|
|
'Bad Error. Can\'t connect to server or something. ' + error
|
|
success: (response, textStatus, jqXHR) ->
|
|
toBeDelayed = false
|
|
return unless jqXHR.status is 200
|
|
isPasswordCorrect = true
|
|
callback = =>
|
|
if toBeDelayed
|
|
setTimeout callback, 100
|
|
else
|
|
if isPasswordCorrect
|
|
onSuccess()
|
|
else
|
|
message = $.i18n.t('account_settings.wrong_password', defaultValue: 'Wrong Password.')
|
|
err = [message: message, property: 'password', formatted: true]
|
|
forms.applyErrorsToForm($form, err)
|
|
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
|
|
setTimeout callback, 100
|
|
else
|
|
message = $.i18n.t('account_settings.wrong_email', defaultValue: 'Wrong Email.')
|
|
err = [message: message, property: 'email', formatted: true]
|
|
forms.applyErrorsToForm($form, err)
|
|
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
|
|
|
|
deleteAccount: ->
|
|
$.ajax
|
|
type: 'DELETE'
|
|
success: ->
|
|
noty
|
|
timeout: 5000
|
|
text: 'Your account is gone.'
|
|
type: 'success'
|
|
layout: 'topCenter'
|
|
_.delay ->
|
|
window?.webkit?.messageHandlers?.notification?.postMessage(name: "signOut") if window.application.isIPadApp
|
|
Backbone.Mediator.publish("auth:logging-out", {})
|
|
window.tracker?.trackEvent 'Log Out', category:'Homepage' if @id is 'home-view'
|
|
logoutUser($('#login-email').val())
|
|
, 500
|
|
error: (jqXHR, status, error) ->
|
|
console.error jqXHR
|
|
noty
|
|
timeout: 5000
|
|
text: "Deleting account failed with error code #{jqXHR.status}"
|
|
type: 'error'
|
|
layout: 'topCenter'
|
|
url: "/db/user/#{me.id}"
|
|
|
|
resetProgress: ->
|
|
$.ajax
|
|
type: 'POST'
|
|
success: ->
|
|
noty
|
|
timeout: 5000
|
|
text: 'Your progress is gone.'
|
|
type: 'success'
|
|
layout: 'topCenter'
|
|
localStorage.clear()
|
|
me.fetch cache: false
|
|
_.delay (-> window.location.reload()), 1000
|
|
error: (jqXHR, status, error) ->
|
|
console.error jqXHR
|
|
noty
|
|
timeout: 5000
|
|
text: "Resetting progress failed with error code #{jqXHR.status}"
|
|
type: 'error'
|
|
layout: 'topCenter'
|
|
url: "/db/user/#{me.id}/reset_progress"
|
|
|
|
onClickProfilePhotoPanelBody: (e) ->
|
|
return if window.application.isIPadApp # TODO: have an iPad-native way of uploading a photo, since we don't want to load FilePicker on iPad (memory)
|
|
photoContainer = @$el.find('.profile-photo')
|
|
onSaving = =>
|
|
photoContainer.addClass('saving')
|
|
onSaved = (uploadingPath) =>
|
|
@$el.find('#photoURL').val(uploadingPath)
|
|
@$el.find('#photoURL').trigger('change') # cause for some reason editing the value doesn't trigger the jquery event
|
|
me.set('photoURL', uploadingPath)
|
|
photoContainer.removeClass('saving').attr('src', me.getPhotoURL(photoContainer.width()))
|
|
filepicker.pick {mimetypes: 'image/*'}, @onImageChosen(onSaving, onSaved)
|
|
|
|
formatImagePostData: (inkBlob) ->
|
|
url: inkBlob.url, filename: inkBlob.filename, mimetype: inkBlob.mimetype, path: @uploadFilePath, force: true
|
|
|
|
onImageChosen: (onSaving, onSaved) ->
|
|
(inkBlob) =>
|
|
onSaving()
|
|
uploadingPath = [@uploadFilePath, inkBlob.filename].join('/')
|
|
data = @formatImagePostData(inkBlob)
|
|
success = @onImageUploaded(onSaved, uploadingPath)
|
|
$.ajax '/file', type: 'POST', data: data, success: success
|
|
|
|
onImageUploaded: (onSaved, uploadingPath) ->
|
|
(e) =>
|
|
onSaved uploadingPath
|
|
|
|
|
|
#- Misc
|
|
|
|
getSubscriptions: ->
|
|
inputs = ($(i) for i in $('#email-panel input[type="checkbox"].changed', @$el))
|
|
emailNames = (i.attr('name').replace('email_', '') for i in inputs)
|
|
enableds = (i.prop('checked') for i in inputs)
|
|
_.zipObject emailNames, enableds
|
|
|
|
|
|
#- Saving changes
|
|
|
|
save: ->
|
|
$('#settings-tabs input').removeClass 'changed'
|
|
forms.clearFormAlerts(@$el)
|
|
@grabData()
|
|
res = me.validate()
|
|
if res?
|
|
console.error 'Couldn\'t save because of validation errors:', res
|
|
forms.applyErrorsToForm(@$el, res)
|
|
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
|
|
return
|
|
|
|
return unless me.hasLocalChanges()
|
|
|
|
res = me.patch()
|
|
return unless res
|
|
|
|
res.error =>
|
|
errors = JSON.parse(res.responseText)
|
|
forms.applyErrorsToForm(@$el, errors)
|
|
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
|
|
@trigger 'save-user-error'
|
|
res.success (model, response, options) =>
|
|
@trigger 'save-user-success'
|
|
|
|
@trigger 'save-user-began'
|
|
|
|
grabData: ->
|
|
@grabPasswordData()
|
|
@grabOtherData()
|
|
|
|
grabPasswordData: ->
|
|
password1 = $('#password', @$el).val()
|
|
password2 = $('#password2', @$el).val()
|
|
bothThere = Boolean(password1) and Boolean(password2)
|
|
if bothThere and password1 isnt password2
|
|
message = $.i18n.t('account_settings.password_mismatch', defaultValue: 'Password does not match.')
|
|
err = [message: message, property: 'password2', formatted: true]
|
|
forms.applyErrorsToForm(@$el, err)
|
|
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
|
|
return
|
|
if bothThere
|
|
me.set('password', password1)
|
|
else if password1
|
|
message = $.i18n.t('account_settings.password_repeat', defaultValue: 'Please repeat your password.')
|
|
err = [message: message, property: 'password2', formatted: true]
|
|
forms.applyErrorsToForm(@$el, err)
|
|
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
|
|
|
|
grabOtherData: ->
|
|
@$el.find('#name-input').val @suggestedName if @suggestedName
|
|
me.set 'name', @$el.find('#name-input').val()
|
|
me.set 'email', @$el.find('#email').val()
|
|
for emailName, enabled of @getSubscriptions()
|
|
me.setEmailSubscription emailName, enabled
|
|
|
|
me.set('photoURL', @$el.find('#photoURL').val())
|
|
|
|
permissions = []
|
|
|
|
unless application.isProduction()
|
|
adminCheckbox = @$el.find('#admin')
|
|
if adminCheckbox.length
|
|
permissions.push 'admin' if adminCheckbox.prop('checked')
|
|
godmodeCheckbox = @$el.find('#godmode')
|
|
if godmodeCheckbox.length
|
|
permissions.push 'godmode' if godmodeCheckbox.prop('checked')
|
|
me.set('permissions', permissions)
|