Fix bugquest bugs

Fix link to /teachers/classes (fixes bugquest#20)

Fix edit button color/icon (bugquest#23)

Fix bugquest#34

Fix password input width (bugquest#33)

Center new pasword text

Fix teacher password reset endpoint (bugquest#4)

Refactor+use NewHomeView logic for user page button (Fixes bugquest#2)

Refactor teacher-password-reset endpoint

This makes it much easier to prevent collisions with other logic when PUTing new User attributes.

Add regression test for converting to teacher account

Fix email verified links, require login (fix bugquest#16)

Fix me having stale emailVerified value (Fixes bugquest#40)

Don't show JoinClassModal to students

Add paragraph to JoinClassModal (fixes bugquest#14)

Update change-password label text (fixes bugquest#30)

Fix prompting for login on Account Settings page (bugquest #10)

Show validation errors for teacher password reset (bugquest#36)

Show yellow progress dot in My Classes if anyone has started (bugquest#55)

Remove confusing text (bugquest#100)
This commit is contained in:
phoenixeliot 2016-05-20 14:52:04 -07:00
parent f0fa88206d
commit 3d705e5d70
26 changed files with 127 additions and 68 deletions

View file

@ -11,9 +11,14 @@ module.exports =
instance = courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id })
continue if not instance
instance.numCompleted = 0
instance.numStarted = 0
instance.started = false
levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
for userID in instance.get('members')
instance.started = _.any levels.models, (level) ->
return false if level.isLadder()
session = _.find classroom.sessions.models, (session) ->
session.get('creator') is userID and session.get('level').original is level.get('original')
session?
levelCompletes = _.map levels.models, (level) ->
return true if level.isLadder()
#TODO: Hella slow! Do the mapping first!
@ -23,8 +28,6 @@ module.exports =
session?.completed()
if _.every levelCompletes
instance.numCompleted += 1
if _.any levelCompletes
instance.numStarted += 1
calculateEarliestIncomplete: (classroom, courses, courseInstances, students) ->
# Loop through all the combinations of things, return the first one that somebody hasn't finished

View file

@ -1322,6 +1322,9 @@
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: "
youve_been_invited_1: "You've been invited to join "
youve_been_invited_2: ", where you'll learn "
youve_been_invited_3: " with your classmates in CodeCombat."
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:"
@ -1415,9 +1418,9 @@
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"
enter_new_password_below: "Enter new password below:"
change_password: "Change Password"
changed: "Changed"
available_credits: "Available Credits"

View file

@ -52,6 +52,16 @@ module.exports = class Classroom extends CocoModel
}
_.extend options, opts
@fetch(options)
setStudentPassword: (student, password, options) ->
classroomID = @.id
$.ajax {
url: "/db/classroom/#{classroomID}/members/#{student.id}/reset-password"
method: 'POST'
data: { password }
success: => @trigger 'save-password:success'
error: (response) => @trigger 'save-password:error', response.responseJSON
}
getLevels: (options={}) ->
# options: courseID, withoutLadderLevels

View file

@ -68,6 +68,11 @@ module.exports = class User extends CocoModel
isTeacher: ->
return @get('role') in ['teacher', 'technology coordinator', 'advisor', 'principal', 'superintendent', 'parent']
justPlaysCourses: ->
# This heuristic could be better, but currently we don't add to me.get('courseInstances') for single-player anonymous intro courses, so they have to beat a level without choosing a hero.
return true if me.get('role') is 'student'
return me.get('stats')?.gamesCompleted and not me.get('heroConfig')
isSessionless: ->
# TODO: Fix old users who got mis-tagged as teachers

View file

@ -57,7 +57,7 @@ _.extend UserSchema.properties,
gender: {type: 'string'} # , 'enum': ['male', 'female', 'secret', 'trans', 'other']
# NOTE: ageRange enum changed on 4/27/16 from ['0-13', '14-17', '18-24', '25-34', '35-44', '45-100']
ageRange: {type: 'string'} # 'enum': ['13-15', '16-17', '18-24', '25-34', '35-44', '45-100']
password: {type: 'string', maxLength: 256, minLength: 2, title: 'Password'}
password: c.passwordString
passwordReset: {type: 'string'}
photoURL: {type: 'string', format: 'image-file', title: 'Profile Picture', description: 'Upload a 256x256px or larger image to serve as your profile picture.'}

View file

@ -15,6 +15,7 @@ me.object = (ext, props) -> combine({type: 'object', additionalProperties: false
me.array = (ext, items) -> combine({type: 'array', items: items or {}}, ext)
me.shortString = (ext) -> combine({type: 'string', maxLength: 100}, ext)
me.pct = (ext) -> combine({type: 'number', maximum: 1.0, minimum: 0.0}, ext)
me.passwordString = {type: 'string', maxLength: 256, minLength: 2, title: 'Password'}
# Dates should usually be strings, ObjectIds should be strings: https://github.com/codecombat/codecombat/issues/1384
me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext) # old

View file

@ -157,9 +157,14 @@
.glyphicon
color: $gray-light
.edit-student-link
color: black
.remove-student-link
color: $burgandy
.edit-student-link, .remove-student-link
display: inline-block
color: $burgandy
font-weight: bold
text-decoration: underline
line-height: 16px

View file

@ -0,0 +1,4 @@
#edit-student-modal
.new-password-input
width: 300px
text-align: center

View file

@ -6,3 +6,6 @@
.glyphicon
font-size: 20pt
.btn-lg
min-width: 246px

View file

@ -13,6 +13,12 @@ block modal-header-content
block modal-body-content
if view.classroom.loaded
p
span.spr(data-i18n="courses.youve_been_invited_1")
span= view.classroom.get('name')
span.spr(data-i18n="courses.youve_been_invited_2")
span= view.classroom.capitalLanguage
span.spl(data-i18n="courses.youve_been_invited_3")
p
span.spr(data-i18n="courses.by_joining_1")
span= view.classroom.get('name')

View file

@ -229,7 +229,7 @@ mixin studentRow(student)
td
.pull-right
a.edit-student-link.small.center-block.text-center.m-r-2(data-student-id=student.id)
div.glyphicon.glyphicon-remove
div.glyphicon.glyphicon-edit
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

View file

@ -107,7 +107,7 @@ mixin progressDot(classroom, course, index)
- var started = 0;
if courseInstance
- complete = courseInstance.numCompleted
- started = courseInstance.numStarted
- started = courseInstance.started
- dotClass = complete === total ? 'forest' : started ? 'gold' : '';
- var progressDotContext = {total: total, complete: complete};
.progress-dot(class=dotClass, data-title=view.progressDotTemplate(progressDotContext))

View file

@ -85,7 +85,7 @@ mixin box
h6(data-i18n="new_home.want_coco")
a.btn.btn-primary.btn-lg.btn-block(href=view.demoRequestURL, data-i18n="new_home.get_started")
else if view.justPlaysCourses()
else if me.justPlaysCourses()
div
a.btn.btn-forest.btn-lg.btn-block(href=view.playURL, data-i18n="courses.continue_playing")
div

View file

@ -8,12 +8,10 @@ block modal-header-content
block modal-body-content
.text-center
if view.user.get('emailVerified')
div.m-b-1
p
span(data-i18n="teacher.send_email_to")
div
p.m-b-3
= 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")
@ -22,10 +20,11 @@ block modal-body-content
span(data-i18n="teacher.send_recovery_email")
else
div.m-b-1
span(data-i18n="teacher.change_password")
span :
div
span(data-i18n="teacher.enter_new_password_below")
div.m-b-2.form-group(class=(state.get('errorMessage') ? 'has-error' : ''))
input.new-password-input(placeholder="type a new password here" value=state.get('newPassword'))
div.help-block.error-help-block.m-t-1.small
span=state.get('errorMessage')
if state.get('passwordChanged')
button.change-password-btn.btn.btn-lg.btn-primary.uppercase.disabled
span(data-i18n="teacher.changed")

View file

@ -10,18 +10,21 @@ block content
.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
if view.userID !== me.id
a.login-button.btn.btn-navy.btn-lg(data-i18n="login.log_in")
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
a.btn.btn-lg.btn-forest(href="/teachers/classes")
span(data-i18n="new_home.goto_classes")
else if me.justPlaysCourses()
div.m-b-1
a.btn.btn-forest.btn-lg(href="/courses", data-i18n="courses.continue_playing")
div
a.btn.btn-primary.btn-lg.play-btn(href="/courses", data-i18n="new_home.view_progress")
else
a.btn.btn-lg.btn-navy(href="/play")
span(data-i18n="account.back_to_game")
| Go play some more levels!
div.m-b-1
a.btn.btn-forest.btn-lg.play-btn(href="/play", data-i18n="courses.continue_playing")
div
a.btn.btn-primary.btn-lg(href="/user/#{me.getSlugOrID()}", data-i18n="new_home.view_profile")
else if state.get('verifyStatus') === "error"
.alert.alert-danger.center-block
.glyphicon.glyphicon-remove-circle.m-r-1

View file

@ -44,12 +44,12 @@ module.exports = class NewHomeView extends RootView
@supermodel.loadCollection(@trialRequests)
isHourOfCodeWeek = false # Temporary: default to /hoc flow during the main event week
if isHourOfCodeWeek and (@isNewPlayer() or (@justPlaysCourses() and me.isAnonymous()))
if isHourOfCodeWeek and (@isNewPlayer() or (me.justPlaysCourses() and me.isAnonymous()))
# Go/return straight to playing single-player HoC course on Play click
@playURL = '/hoc?go=true'
@alternatePlayURL = '/play'
@alternatePlayText = 'home.play_campaign_version'
else if @justPlaysCourses()
else if me.justPlaysCourses()
# Save players who might be in a classroom from getting into the campaign
@playURL = '/courses'
@alternatePlayURL = '/play'
@ -123,11 +123,6 @@ module.exports = class NewHomeView extends RootView
$(@).find('.course-duration .unit').text($.i18n.t(if duration is '1' then 'units.hour' else 'units.hours'))
@$el.find('#semester-duration').text levels[level].total
justPlaysCourses: ->
# This heuristic could be better, but currently we don't add to me.get('courseInstances') for single-player anonymous intro courses, so they have to beat a level without choosing a hero.
return true if me.get('role') is 'student'
return me.get('stats')?.gamesCompleted and not me.get('heroConfig')
isNewPlayer: ->
not me.get('stats')?.gamesCompleted and not me.get('heroConfig')

View file

@ -1,6 +1,7 @@
RootView = require 'views/core/RootView'
template = require 'templates/account/account-settings-root-view'
AccountSettingsView = require './AccountSettingsView'
CreateAccountModal = require 'views/core/CreateAccountModal'
module.exports = class AccountSettingsRootView extends RootView
id: "account-settings-root-view"
@ -21,6 +22,9 @@ module.exports = class AccountSettingsRootView extends RootView
@listenTo @accountSettingsView, 'save-user-success', @onUserSaveSuccess
@listenTo @accountSettingsView, 'save-user-error', @onUserSaveError
afterInsert: ->
@openModalView new CreateAccountModal() if me.get('anonymous')
onInputChanged: ->
@$el.find('#save-button')
.text($.i18n.t('common.save', defaultValue: 'Save'))
@ -45,4 +49,3 @@ module.exports = class AccountSettingsRootView extends RootView
.text($.i18n.t('account_settings.error_saving', defaultValue: 'Error Saving'))
.removeClass('btn-success')
.addClass('btn-danger', 500)

View file

@ -3,7 +3,6 @@ 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')
@ -26,10 +25,6 @@ module.exports = class AccountSettingsView extends CocoView
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

View file

@ -92,7 +92,7 @@ module.exports = class CoursesView extends RootView
@renderSelectors '#join-class-form'
return
@renderSelectors '#join-class-form'
if me.get('emailVerified')
if me.get('emailVerified') or me.isStudent()
newClassroom = new Classroom()
jqxhr = newClassroom.joinWithCode(@classCode)
@listenTo newClassroom, 'join:success', -> @onJoinClassroomSuccess(newClassroom)

View file

@ -231,7 +231,7 @@ module.exports = class TeacherClassView extends RootView
onClickEditStudentLink: (e) ->
user = @students.get($(e.currentTarget).data('student-id'))
modal = new EditStudentModal({ user })
modal = new EditStudentModal({ user, @classroom })
@openModalView(modal)
onClickRemoveStudentLink: (e) ->

View file

@ -12,15 +12,21 @@ module.exports = class EditStudentModal extends ModalView
'click .change-password-btn:not(.disabled)': 'onClickChangePassword'
'input .new-password-input': 'onChangeNewPasswordInput'
initialize: ({ @user }) ->
initialize: ({ @user, @classroom }) ->
@supermodel.trackRequest @user.fetch()
@utils = require 'core/utils'
@state = new State({
emailSent: false
passwordChanged: false
newPassword: ""
errorMessage: ""
})
@listenTo @state, 'change', @render
@listenTo @classroom, 'save-password:success', ->
@state.set { passwordChanged: true, errorMessage: "" }
@listenTo @classroom, 'save-password:error', (error) ->
@state.set({ errorMessage: error.message })
# TODO: Show an error. (password too short)
onClickSendRecoveryEmail: ->
email = @user.get('email')
@ -28,13 +34,7 @@ module.exports = class EditStudentModal extends ModalView
@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)
@classroom.setStudentPassword(@user, @state.get('newPassword'))
onChangeNewPasswordInput: (e) ->
@state.set { newPassword: $(e.currentTarget).val() }, { silent: true }

View file

@ -7,6 +7,9 @@ module.exports = class EmailVerifiedView extends RootView
id: 'email-verified-view'
template: template
events:
'click .login-button': 'onClickLoginButton'
initialize: (options, @userID, @verificationCode) ->
super(options)
@state = new State(@getInitialState())
@ -16,8 +19,13 @@ module.exports = class EmailVerifiedView extends RootView
@listenTo @state, 'change', @render
@listenTo @user, 'email-verify-success', ->
@state.set { verifyStatus: 'success' }
me.fetch()
@listenTo @user, 'email-verify-error', ->
@state.set { verifyStatus: 'error' }
getInitialState: ->
verifyStatus: 'pending'
onClickLoginButton: (e) ->
AuthModal = require 'views/core/AuthModal'
@openModalView(new AuthModal())

View file

@ -1,6 +1,7 @@
_ = require 'lodash'
utils = require '../lib/utils'
errors = require '../commons/errors'
schemas = require '../../app/schemas/schemas'
wrap = require 'co-express'
Promise = require 'bluebird'
database = require '../commons/database'
@ -18,7 +19,7 @@ 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')
classroom = yield Classroom.findOne({ code: code.toLowerCase() }).select('name ownerID aceConfig')
if not classroom
res.status(404).send({})
classroom = classroom.toObject()
@ -185,3 +186,22 @@ module.exports =
yield CourseInstance.update({_id: {$in: freeCourseInstanceIDs}}, { $addToSet: { members: req.user._id }})
yield User.update({ _id: req.user._id }, { $addToSet: { courseInstances: { $each: freeCourseInstanceIDs } } })
res.send(classroom.toObject({req: req}))
setStudentPassword: wrap (req, res, next) ->
newPassword = req.body.password
{ classroomID, memberID } = req.params
teacherID = req.user.id
return next() if teacherID is memberID or not newPassword
ownedClassrooms = yield Classroom.find({ ownerID: mongoose.Types.ObjectId(teacherID) })
ownedStudentIDs = _.flatten ownedClassrooms.map (c) ->
c.get('members').map (id) ->
id.toString()
return next() unless memberID in ownedStudentIDs
student = yield User.findById(memberID)
if student.get('emailVerified')
return next new errors.Forbidden("Can't reset password for a student that has verified their email address.")
{ valid, error } = tv4.validateResult(newPassword, schemas.passwordString)
unless valid
throw new errors.UnprocessableEntity(error.message)
yield student.update({ $set: { passwordHash: User.hashPassword(newPassword) } })
res.status(200).send({})

View file

@ -94,19 +94,3 @@ module.exports =
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

@ -60,6 +60,7 @@ module.exports.setup = (app) ->
app.get('/db/classroom/:handle/courses/:courseID/levels', mw.classrooms.fetchLevelsForCourse)
app.get('/db/classroom/:handle/member-sessions', mw.classrooms.fetchMemberSessions)
app.get('/db/classroom/:handle/members', mw.classrooms.fetchMembers) # TODO: Use mw.auth?
app.post('/db/classroom/:classroomID/members/:memberID/reset-password', mw.classrooms.setStudentPassword)
app.post('/db/classroom/:anything/members', mw.auth.checkLoggedIn(), mw.classrooms.join)
app.get('/db/classroom/:handle', mw.auth.checkLoggedIn()) # TODO: Finish migrating route, adding now so 401 is returned
@ -77,8 +78,7 @@ 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.put('/db/user/:handle', mw.users.resetEmailVerifiedFlag)
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)

View file

@ -235,6 +235,18 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl
classroom = yield Classroom.findById(classroom.id)
expect(classroom.get('members').length).toBe(0)
done()
it 'changes the role regardless of emailVerified', utils.wrap (done) ->
user = yield utils.initUser()
user.set('emailVerified', true)
yield user.save()
yield utils.loginUser(user)
attrs = user.toObject()
attrs.role = 'teacher'
[res, body] = yield request.putAsync { uri: getURL('/db/user/'+user.id), json: attrs }
user = yield User.findById(user.id)
expect(user.get('role')).toBe('teacher')
done()
it 'ignores attempts to change away from a teacher role', utils.wrap (done) ->
user = yield utils.initUser()