diff --git a/app/application.coffee b/app/application.coffee index be9db6a97..77c1a568b 100644 --- a/app/application.coffee +++ b/app/application.coffee @@ -1,5 +1,6 @@ FacebookHandler = require 'lib/FacebookHandler' GPlusHandler = require 'lib/GPlusHandler' +LinkedInHandler = require 'lib/LinkedInHandler' locale = require 'locale/locale' {me} = require 'lib/auth' Tracker = require 'lib/Tracker' @@ -35,7 +36,7 @@ Application = initialize: -> @facebookHandler = new FacebookHandler() @gplusHandler = new GPlusHandler() $(document).bind 'keydown', preventBackspace - + @linkedinHandler = new LinkedInHandler() preload(COMMON_FILES) $.i18n.init { lng: me?.lang() ? 'en' diff --git a/app/assets/main.html b/app/assets/main.html index 761eac10c..0521041b5 100644 --- a/app/assets/main.html +++ b/app/assets/main.html @@ -42,7 +42,18 @@ - + + + + + diff --git a/app/lib/LinkedInHandler.coffee b/app/lib/LinkedInHandler.coffee new file mode 100644 index 000000000..415e142ac --- /dev/null +++ b/app/lib/LinkedInHandler.coffee @@ -0,0 +1,28 @@ +CocoClass = require 'lib/CocoClass' +{me} = require 'lib/auth' +{backboneFailure} = require 'lib/errors' +storage = require 'lib/storage' + + +module.exports = LinkedInHandler = class LinkedInHandler extends CocoClass + constructor: -> + super() + + subscriptions: + 'linkedin-loaded':'onLinkedInLoaded' + + onLinkedInLoaded: (e) => + IN.Event.on IN, "auth", @onLinkedInAuth + + onLinkedInAuth: (e) => console.log "Authorized with LinkedIn" + + constructEmployerAgreementObject: (cb) => + IN.API.Profile("me") + .fields(["positions","public-profile-url","id","first-name","last-name","email-address"]) + .error(cb) + .result (profiles) => + cb null, profiles.values[0] + + + destroy: -> + super() diff --git a/app/lib/auth.coffee b/app/lib/auth.coffee index 4fa94e61f..9a75af184 100644 --- a/app/lib/auth.coffee +++ b/app/lib/auth.coffee @@ -19,6 +19,14 @@ module.exports.createUser = (userObject, failure=backboneFailure, nextURL=null) error: failure, success: -> if nextURL then window.location.href = nextURL else window.location.reload() }) + +module.exports.createUserWithoutReload = (userObject, failure=backboneFailure) -> + user = new User(userObject) + user.save({}, { + error: failure + success: -> + Backbone.Mediator.publish("created-user-without-reload") + }) module.exports.loginUser = (userObject, failure=genericFailure) -> jqxhr = $.post('/auth/login', diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 4ec00f8c4..b19535d1d 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -53,7 +53,7 @@ UserSchema = c.object {}, #Internationalization stuff preferredLanguage: {type: 'string', default: 'en', 'enum': c.getLanguageCodeArray()} - + signedCLA: c.date({title: 'Date Signed the CLA'}) wizard: c.object {}, colorConfig: c.object {additionalProperties: c.colorConfig()} @@ -109,6 +109,11 @@ UserSchema = c.object {}, jobProfileApproved: {title: 'Job Profile Approved', type: 'boolean', description: 'Whether your profile has been approved by CodeCombat.'} jobProfileNotes: {type: 'string', maxLength: 1000, title: 'Our Notes', description: "CodeCombat's notes on the candidate.", format: 'markdown', default: ''} employerAt: c.shortString {description: "If given employer permissions to view job candidates, for which employer?"} + signedEmployerAgreement: c.object {}, + linkedinID: c.shortString {title:"LinkedInID", description: "The user's LinkedIn ID when they signed the contract."} + date: c.date {title: "Date signed employer agreement"} + data: c.object + c.extendBasicProperties UserSchema, 'user' diff --git a/app/styles/base.sass b/app/styles/base.sass index afa9fbcc9..d16ce64d9 100644 --- a/app/styles/base.sass +++ b/app/styles/base.sass @@ -133,6 +133,14 @@ a[data-toggle="modal"] background-color: transparent margin: 0 14px border-bottom-color: #ccc + .modal-footer.linkedin + text-align: center + .signin-text + font-size: 15px + padding-bottom: 10px + .login-link + cursor: pointer + // Bigger versions of some Bootstrap icons // TODO: make the non-white versions of these if we ever need them diff --git a/app/styles/employers.sass b/app/styles/employers.sass index d0da4c1b2..1c7538cf2 100644 --- a/app/styles/employers.sass +++ b/app/styles/employers.sass @@ -1,4 +1,6 @@ #employers-view + #see-candidates + cursor: pointer .tablesorter //img // display: none diff --git a/app/templates/employers.jade b/app/templates/employers.jade index f69721ae4..8f44247d0 100644 --- a/app/templates/employers.jade +++ b/app/templates/employers.jade @@ -12,9 +12,11 @@ block content span(data-i18n="employers.candidates_count_many") many | span(data-i18n="employers.candidates_count_suffix") highly skilled and vetted developers looking for work. - h3 - a(title='Contact', tabindex=-1, data-toggle="coco-modal", data-target="modal/employer_signup", data-i18n="employers.contact_george") Contact George to see our candidates + if !isEmployer + h3 + a#see-candidates(title='Contact', tabindex=-1, data-toggle="coco-modal", data-target="modal/employer_signup") Click here to see candidates + if candidates.length table.table.table-condensed.table-hover.table-responsive.tablesorter thead diff --git a/app/templates/modal/employer_signup_modal.jade b/app/templates/modal/employer_signup_modal.jade index 809b26452..7bee4aabb 100644 --- a/app/templates/modal/employer_signup_modal.jade +++ b/app/templates/modal/employer_signup_modal.jade @@ -1,9 +1,68 @@ extends /templates/modal/modal_base block modal-header-content - h3(data-i18n="employer_signup.title") Hire CodeCombat Players + if userIsAnonymous || !userIsAuthorized + h3(data-i18n="employer_signup.title") Sign up to hire CodeCombat players! + else + h3 CodeCombat Placement Agreement block modal-body-content - h4(data-i18n="employer_signup.sub_heading") Let us find your next brilliant developers. + if userIsAnonymous + if userIsAuthorized + | You appear to be authorized on CodeCombat with LinkedIn. + else + h4(data-i18n="employer_signup.sub_heading") Let us find your next brilliant developers. + p Create an account to get started! + .form + .form-group + label.control-label(for="signup-email", data-i18n="general.email") Email + input#signup-email.form-control.input-large(name="email",type="email") + .form-group + label.control-label(for="signup-password", data-i18n="general.password") Password + input#signup-password.input-large.form-control(name="password", type="password") + else if !userIsAuthorized + .modal-footer.linkedin + p Please sign into your LinkedIn account to verify your identity. + script(type="in/Login" id="linkedInAuthButton" data-onAuth="contractCallback") - p(data-i18n="employer_signup.pitch_body") When you hire one of our players, you will pay CodeCombat 18% of her first-year salary, payable within 30 days of when she starts working. We will fully refund our placement fee if she leaves or is fired within 90 days. Cool? Email george@codecombat.com to get set up with employer permissions to see our candidates. + else + | Please agree to our terms before accessing our candidates. + br + br + b Who we are: + | CodeCombat is a programming game that both teaches and vets programmers. If you accept this agreement, we will let you hire the most talented developers on our platform. + br + br + b Placement fee: + | If you hire our any of our players, you agree to pay us 15% of the candidate's first year annualized starting base salary. The fee is due on the first day that the candidate is employed and is 100% refundable for up to 90 day if the candidate doesn't remain employed at the company for any reason. + br + br + b Interns are free: + | We will not bill you for interns and part time hires (remote or onsite) hired through this site, provided they do not become full time hires within 1 year of their start date. If they do become full time hires within 1 year of their start date, we will invoice you 15% of their first year's annualized starting base salary on their first day of full time employment. For these hires, the 90 day guarantee does not apply. + br + br + | By clicking Agree, you are agreeing to CodeCombat's Placement Agreement on behalf of your company. You also consent to CodeCombat storing basic LinkedIn profile data for verification purposes, including your name, email, public profile URL, and work history. +block modal-footer + if userIsAnonymous + if !userIsAuthorized + .modal-footer.linkedin + b.signin-text Sign in with LinkedIn to complete the registration process. + script(type="in/Login" id="linkedInAuthButton" data-onAuth="contractCallback") + br + br + | Already have a CodeCombat account? + a.login-link(data-toggle="coco-modal", data-target="modal/login") Log in to continue! + else + .modal-footer.linkedin + a.login-link(data-toggle="coco-modal", data-target="modal/login") Please log in to continue. + else if !userIsAnonymous && !userIsAuthorized + .modal-footer.linkedin + | We will record your name and work history for verification purposes. + else if userIsAuthorized && !userHasSignedContract + .modal-footer.linkedin + button.btn.btn-primary(id="contract-agreement-button") I agree + else + .modal-footer.linkedin + | Thanks #{firstName}! You've already agreed to the contract. + + \ No newline at end of file diff --git a/app/views/employers_view.coffee b/app/views/employers_view.coffee index 19c57aed2..59593d121 100644 --- a/app/views/employers_view.coffee +++ b/app/views/employers_view.coffee @@ -2,6 +2,7 @@ View = require 'views/kinds/RootView' template = require 'templates/employers' app = require 'application' User = require 'models/User' +{me} = require 'lib/auth' CocoCollection = require 'collections/CocoCollection' EmployerSignupView = require 'views/modal/employer_signup_modal' @@ -27,6 +28,9 @@ module.exports = class EmployersView extends View getRenderData: -> c = super() c.candidates = @candidates.models + userPermissions = me.get('permissions') || [] + + c.isEmployer = _.contains userPermissions, "employer" c.moment = moment c diff --git a/app/views/modal/employer_signup_modal.coffee b/app/views/modal/employer_signup_modal.coffee index de66c007d..9ff3932a6 100644 --- a/app/views/modal/employer_signup_modal.coffee +++ b/app/views/modal/employer_signup_modal.coffee @@ -1,7 +1,111 @@ View = require 'views/kinds/ModalView' template = require 'templates/modal/employer_signup_modal' +forms = require('lib/forms') +User = require 'models/User' +auth = require('lib/auth') +me = auth.me module.exports = class EmployerSignupView extends View id: "employer-signup" template: template closeButton: true + + + subscriptions: + "server-error": "onServerError" + "created-user-without-reload": "linkedInAuth" + + events: + "click #contract-agreement-button": "agreeToContract" + + + constructor: (options) -> + super(options) + @authorizedWithLinkedIn = IN?.User?.isAuthorized() + window.tracker?.trackEvent 'Started Employer Signup' + @reloadWhenClosed = false + window.contractCallback = => + @authorizedWithLinkedIn = IN?.User?.isAuthorized() + @render() + + onServerError: (e) -> + @disableModalInProgress(@$el) + + afterInsert: -> + super() + linkedInButtonParentElement = document.getElementById("linkedInAuthButton")?.parentNode + if linkedInButtonParentElement + IN.parse() + if me.get('anonymous') + $(".IN-widget").get(0).addEventListener('click', @createAccount, true) + console.log "Parsed linkedin button element!" + console.log linkedInButtonParentElement + + getRenderData: -> + context = super() + context.userIsAuthorized = @authorizedWithLinkedIn + context.userHasSignedContract = "employer" in me.get("permissions") + context.userIsAnonymous = context.me.get('anonymous') + context + + agreeToContract: -> + application.linkedinHandler.constructEmployerAgreementObject (err, profileData) => + if err? then return handleAgreementFailure err + $.ajax + url: "/db/user/#{me.id}/agreeToEmployerAgreement" + data: profileData + type: "POST" + success: @handleAgreementSuccess + error: @handleAgreementFailure + + handleAgreementSuccess: (result) -> + window.tracker?.trackEvent 'Employer Agreed to Contract' + me.fetch() + window.location.reload() + + handleAgreementFailure: (error) -> + alert "There was an error signing the contract. Please contact team@codecombat.com with this error: #{error.responseText}" + + createAccount: (e) => + window.tracker?.trackEvent 'Finished Employer Signup' + console.log "Tried to create account!" + e.stopPropagation() + forms.clearFormAlerts(@$el) + userObject = forms.formToObject @$el + delete userObject.subscribe + for key, val of me.attributes when key in ["preferredLanguage", "testGroupNumber", "dateCreated", "wizardColor1", "name", "music", "volume", "emails"] + userObject[key] ?= val + subscribe = true + #TODO: Enable all email subscriptions + + userObject.emails ?= {} + userObject.emails.generalNews ?= {} + userObject.emails.generalNews.enabled = subscribe + res = tv4.validateMultiple userObject, User.schema + return forms.applyErrorsToForm(@$el, res.errors) unless res.valid + window.tracker?.trackEvent 'Finished Signup' + @enableModalInProgress(@$el) + auth.createUserWithoutReload userObject, null + + linkedInAuth: (e) => + console.log "Authorizing with linkedin" + @listenTo me,"sync", -> + IN.User.authorize(@recordUserDetails, @) + me.fetch() + @reloadWhenClosed = true + + + recordUserDetails: (e) => + #TODO: refactor this out + @render() + + destroy: -> + reloadWhenClosed = @reloadWhenClosed + super() + if reloadWhenClosed + window.location.reload() + + + + + \ No newline at end of file diff --git a/server/users/user_handler.coffee b/server/users/user_handler.coffee index cc6f279d3..30e49ac6f 100644 --- a/server/users/user_handler.coffee +++ b/server/users/user_handler.coffee @@ -14,7 +14,7 @@ LevelSessionHandler = require '../levels/sessions/level_session_handler' serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset'] privateProperties = [ 'permissions', 'email', 'firstName', 'lastName', 'gender', 'facebookID', - 'gplusID', 'music', 'volume', 'aceConfig', 'employerAt' + 'gplusID', 'music', 'volume', 'aceConfig', 'employerAt', 'signedEmployerAgreement' ] candidateProperties = [ 'jobProfile', 'jobProfileApproved', 'jobProfileNotes' @@ -182,6 +182,7 @@ UserHandler = class UserHandler extends Handler getByRelationship: (req, res, args...) -> return @agreeToCLA(req, res) if args[1] is 'agreeToCLA' + return @agreeToEmployerAgreement(req,res) if args[1] is 'agreeToEmployerAgreement' return @avatar(req, res, args[0]) if args[1] is 'avatar' return @getNamesByIDs(req, res) if args[1] is 'names' return @nameToID(req, res, args[0]) if args[1] is 'nameToID' @@ -231,7 +232,31 @@ UserHandler = class UserHandler extends Handler return @sendDatabaseError(res, err) if err documents = (LevelSessionHandler.formatEntity(req, doc) for doc in documents) @sendSuccess(res, documents) - + agreeToEmployerAgreement: (req, res) -> + userIsAnonymous = req.user?.get('anonymous') + if userIsAnonymous then return errors.unauthorized(res, "You need to be logged in to agree to the employer agreeement.") + profileData = req.body + #TODO: refactor this bit to make it more elegant + if not profileData.id or not profileData.positions or not profileData.emailAddress or not profileData.firstName or not profileData.lastName + return errors.badInput(res, "You need to have a more complete profile to sign up for this service.") + @modelClass.findById(req.user.id).exec (err, user) => + if user.get('employerAt') or user.get('signedEmployerAgreement') or "employer" in user.get('permissions') + return errors.conflict(res, "You already have signed the agreement!") + #TODO: Search for the current position + employerAt = _.filter(profileData.positions.values,"isCurrent")[0]?.company.name ? "Not available" + signedEmployerAgreement = + linkedinID: profileData.id + date: new Date() + data: profileData + updateObject = + "employerAt": employerAt + "signedEmployerAgreement": signedEmployerAgreement + $push: "permissions":'employer' + + User.update {"_id": req.user.id}, updateObject, (err, result) => + if err? then return errors.serverError(res, "There was an issue updating the user object to reflect employer status: #{err}") + res.send({"message": "The agreement was successful."}) + res.end() getCandidates: (req, res) -> authorized = req.user.isAdmin() or ('employer' in req.user.get('permissions')) since = (new Date((new Date()) - 2 * 30.4 * 86400 * 1000)).toISOString()