diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee index ba180df23..ae5d9664c 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.coffee @@ -223,7 +223,7 @@ class App.Controller extends Spine.Controller # remember requested url if !checkOnly location = window.location.hash - if location isnt '#login' && location isnt '#logout' && location isnt '#keyboard_shortcuts' + if location && location isnt '#login' && location isnt '#logout' && location isnt '#keyboard_shortcuts' @Config.set('requested_url', location) return false if checkOnly @@ -559,16 +559,20 @@ class App.Controller extends Spine.Controller return if !@initLoadingDoneDelay @clearDelay(@initLoadingDoneDelay) + renderScreenSuccess: (data) -> + App.TaskManager.touch(@task_key) if @task_key + @html App.view('generic/error/success')(data) + renderScreenError: (data) -> - App.TaskManager.touch(@task_key) + App.TaskManager.touch(@task_key) if @task_key @html App.view('generic/error/generic')(data) renderScreenNotFound: (data) -> - App.TaskManager.touch(@task_key) + App.TaskManager.touch(@task_key) if @task_key @html App.view('generic/error/not_found')(data) renderScreenUnauthorized: (data) -> - App.TaskManager.touch(@task_key) + App.TaskManager.touch(@task_key) if @task_key @html App.view('generic/error/unauthorized')(data) locationVerify: (e) => diff --git a/app/assets/javascripts/app/controllers/default_route.coffee b/app/assets/javascripts/app/controllers/default_route.coffee index bb27f5425..23d57eedc 100644 --- a/app/assets/javascripts/app/controllers/default_route.coffee +++ b/app/assets/javascripts/app/controllers/default_route.coffee @@ -28,5 +28,5 @@ class DefaultRouter extends App.Controller @navigate '#dashboard', true -App.Config.set( '', DefaultRouter, 'Routes' ) -App.Config.set( '/', DefaultRouter, 'Routes' ) +App.Config.set('', DefaultRouter, 'Routes') +App.Config.set('/', DefaultRouter, 'Routes') diff --git a/app/assets/javascripts/app/controllers/email_verify.coffee b/app/assets/javascripts/app/controllers/email_verify.coffee new file mode 100644 index 000000000..4e8c60f6e --- /dev/null +++ b/app/assets/javascripts/app/controllers/email_verify.coffee @@ -0,0 +1,55 @@ +class Index extends App.Controller + constructor: -> + super + return if !@authenticate() + @verifyCall() + + verifyCall: => + @ajax( + id: 'email_verify' + type: 'POST' + url: @apiPath + '/users/email_verify' + data: JSON.stringify(token: @token) + processData: true + success: @success + error: @error + ) + + success: => + new Success(el: @el) + + error: => + new Fail(el: @el) + +class Success extends App.ControllerContent + constructor: -> + super + @render() + + # rerender view, e. g. on language change + @bind 'ui:rerender', => + @render() + + render: => + @renderScreenSuccess( + detail: 'Woo hoo! Your email is verified!' + ) + delay = => + @navigate '#' + @delay(delay, 20500) + +class Fail extends App.ControllerContent + constructor: -> + super + @render() + + # rerender view, e. g. on language change + @bind 'ui:rerender', => + @render() + + render: => + @renderScreenError( + detail: 'Unable to verify email. Please contact your administrator.' + ) + +App.Config.set('email_verify/:token', Index, 'Routes') diff --git a/app/assets/javascripts/app/controllers/login.coffee b/app/assets/javascripts/app/controllers/login.coffee index 106a12297..17a1aebc9 100644 --- a/app/assets/javascripts/app/controllers/login.coffee +++ b/app/assets/javascripts/app/controllers/login.coffee @@ -136,4 +136,4 @@ class Index extends App.ControllerContent 600 ) -App.Config.set( 'login', Index, 'Routes' ) +App.Config.set('login', Index, 'Routes') diff --git a/app/assets/javascripts/app/controllers/password_reset.coffee b/app/assets/javascripts/app/controllers/password_reset.coffee index 37cc4137c..8373c1d75 100644 --- a/app/assets/javascripts/app/controllers/password_reset.coffee +++ b/app/assets/javascripts/app/controllers/password_reset.coffee @@ -64,8 +64,8 @@ class Index extends App.ControllerContent if data.token && @Config.get('developer_mode') is true redirect = => @navigate "#password_reset_verify/#{data.token}" - @delay( redirect, 2000 ) - @render( sent: true ) + @delay(redirect, 2000) + @render(sent: true) else @$('[name=username]').val('') @@ -75,7 +75,7 @@ class Index extends App.ControllerContent ) @formEnable( @el.find('.form-password') ) -App.Config.set( 'password_reset', Index, 'Routes' ) +App.Config.set('password_reset', Index, 'Routes') class Verify extends App.ControllerContent events: @@ -105,10 +105,10 @@ class Verify extends App.ControllerContent url: @apiPath + '/users/password_reset_verify' data: JSON.stringify(params) processData: true - success: @render_change + success: @renderChange ) - render_change: (data) => + renderChange: (data) => if data.message is 'ok' configure_attributes = [ { name: 'password', display: 'Password', tag: 'input', type: 'password', limit: 100, null: false, class: 'input', }, @@ -161,10 +161,10 @@ class Verify extends App.ControllerContent url: @apiPath + '/users/password_reset_verify' data: JSON.stringify(params) processData: true - success: @render_changed + success: @renderChanged ) - render_changed: (data, status, xhr) => + renderChanged: (data, status, xhr) => if data.message is 'ok' App.Auth.login( data: diff --git a/app/assets/javascripts/app/controllers/widget/translation_inline.coffee b/app/assets/javascripts/app/controllers/widget/translation_inline.coffee index ffca5ff16..ac5faa1bb 100644 --- a/app/assets/javascripts/app/controllers/widget/translation_inline.coffee +++ b/app/assets/javascripts/app/controllers/widget/translation_inline.coffee @@ -49,6 +49,7 @@ class Widget extends App.Controller .on 'blur.translation', '.translation', (e) -> element = $(e.target) source = element.attr('title') + return if !source # get new translation translation_new = element.text() @@ -62,6 +63,7 @@ class Widget extends App.Controller App.i18n.setMap(source, translation_new) # replace rest in page + source = source.replace('\'', '\\\'') $(".translation[title='#{source}']").text(translation_new) # update permanent translation mapString diff --git a/app/assets/javascripts/app/controllers/widget/user_signup_check.coffee b/app/assets/javascripts/app/controllers/widget/user_signup_check.coffee new file mode 100644 index 000000000..8d2e9c3c9 --- /dev/null +++ b/app/assets/javascripts/app/controllers/widget/user_signup_check.coffee @@ -0,0 +1,82 @@ +class Widget extends App.Controller + constructor: -> + + # for browser test + App.Event.bind('user_signup_verify', (user) -> + new Modal(user: user) + 'user_signup_verify' + ) + + App.Event.bind('auth:login', (user) => + return if !user + @verifyLater(user.id) + 'user_signup_verify' + ) + currentUserId = App.Session.get('id') + return if !currentUserId + @verifyLater(currentUserId) + + verifyLater: (userId) => + delay = => + @verify(userId) + @delay(delay, 5000, 'user_signup_verify_dialog') + + verify: (userId) -> + return if !userId + user = App.User.find(userId) + return if user.source isnt 'signup' + return if user.verified is true + currentTime = new Date().getTime() + createdAt = Date.parse(user.created_at) + diff = currentTime - createdAt + max = 1000 * 60 * 30 # show message if account is older then 30 minutes + return if diff < max + new Modal(user: user) + +class Modal extends App.ControllerModal + backdrop: false + keyboard: false + head: 'Account not verified' + small: true + buttonClose: false + buttonCancel: false + buttonSubmit: 'Resend verification email' + + constructor: -> + super + + content: => + if !@sent + return App.i18n.translateContent('Your account is not verified. Please click the link in the verification email.') + content = App.i18n.translateContent('We\'ve sent an email to _%s_. Click the link in the email to verify your account.', @user.email) + content += '

' + content += App.i18n.translateContent('If you don\'t see the email, check other places it might be, like your junk, spam, social, or other folders.') + content + + onSubmit: => + @ajax( + id: 'email_verify_send' + type: 'POST' + url: @apiPath + '/users/email_verify_send' + data: JSON.stringify(email: @user.email) + processData: true + success: @success + error: @error + ) + + success: (data) => + @sent = true + @update() + + # if in developer mode, redirect to verify + if data.token && @Config.get('developer_mode') is true + redirect = => + @close() + @navigate "#email_verify/#{data.token}" + App.Delay.set(redirect, 4000) + + error: => + @contentInline = App.i18n.translateContent('Unable to send verify email.') + @update() + +App.Config.set('user_signup', Widget, 'Widgets') diff --git a/app/assets/javascripts/app/views/generic/error/generic.jst.eco b/app/assets/javascripts/app/views/generic/error/generic.jst.eco index 1a97ed816..8d44428f9 100644 --- a/app/assets/javascripts/app/views/generic/error/generic.jst.eco +++ b/app/assets/javascripts/app/views/generic/error/generic.jst.eco @@ -1,4 +1,4 @@
<%- @Icon('diagonal-cross', 'icon-error') %> -

<% if @status isnt undefined: %><%- @T('Status Code') %>: <%= @status %>. <% end %><%= @detail %>

+

<% if @status isnt undefined: %><%- @T('Status Code') %>: <%= @status %>. <% end %><%- @T(@detail) %>

\ No newline at end of file diff --git a/app/assets/javascripts/app/views/generic/error/success.jst.eco b/app/assets/javascripts/app/views/generic/error/success.jst.eco new file mode 100644 index 000000000..61375596b --- /dev/null +++ b/app/assets/javascripts/app/views/generic/error/success.jst.eco @@ -0,0 +1,4 @@ +
+ <%- @Icon('checkmark') %> +

<%- @T(@detail) %>

+
\ No newline at end of file diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a2fb6c755..fbb091a6d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -73,6 +73,10 @@ class UsersController < ApplicationController # if it's a signup, add user to customer role if !current_user + if !params[:signup] + render json: { error_human: 'Only signup is possible!' }, status: :unprocessable_entity + return + end user.updated_by_id = 1 user.created_by_id = 1 @@ -100,6 +104,9 @@ class UsersController < ApplicationController user.role_ids = role_ids user.group_ids = group_ids + # remember source (in case show email verify banner) + user.source = 'signup' + # else do assignment as defined else @@ -150,14 +157,11 @@ class UsersController < ApplicationController # send email verify if params[:signup] && !current_user - token = Token.create(action: 'EmailVerify', user_id: user.id) + result = User.signup_new_token(user) NotificationFactory::Mailer.notification( template: 'signup', user: user, - objects: { - token: token, - user: user, - } + objects: result ) end user_new = User.find(user.id).attributes_with_associations @@ -393,6 +397,106 @@ class UsersController < ApplicationController =begin +Resource: +POST /api/v1/users/email_verify + +Payload: +{ + "token": "SoMeToKeN", +} + +Response: +{ + :message => 'ok' +} + +Test: +curl http://localhost/api/v1/users/email_verify.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN"}' + +=end + + def email_verify + if !params[:token] + render json: { message: 'No token!' }, status: :unprocessable_entity + return + end + + user = User.signup_verify_via_token(params[:token], current_user) + if !user + render json: { message: 'Invalid token!' }, status: :unprocessable_entity + return + end + + render json: { message: 'ok', user_email: user.email }, status: :ok + end + +=begin + +Resource: +POST /api/v1/users/email_verify_send + +Payload: +{ + "email": "some_email@example.com" +} + +Response: +{ + :message => 'ok' +} + +Test: +curl http://localhost/api/v1/users/email_verify_send.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"email": "some_email@example.com"}' + +=end + + def email_verify_send + + if !params[:email] + render json: { message: 'No email!' }, status: :unprocessable_entity + return + end + + # check is verify is possible to send + user = User.find_by(email: params[:email].downcase) + if !user + render json: { error_human: 'No such user!' }, status: :unprocessable_entity + return + end + + #if user.verified == true + # render json: { error_human: 'Already verified!' }, status: :unprocessable_entity + # return + #end + + token = Token.create(action: 'Signup', user_id: user.id) + + result = User.signup_new_token(user) + if result && result[:token] + user = result[:user] + NotificationFactory::Mailer.notification( + template: 'signup', + user: user, + objects: result + ) + + # only if system is in develop mode, send token back to browser for browser tests + if Setting.get('developer_mode') == true + render json: { message: 'ok', token: result[:token].name }, status: :ok + return + end + + # token sent to user, send ok to browser + render json: { message: 'ok' }, status: :ok + return + end + + # unable to generate token + render json: { message: 'failed' }, status: :ok + end + +=begin + Resource: POST /api/v1/users/password_reset diff --git a/app/models/token.rb b/app/models/token.rb index 646b278be..625dbaf96 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -8,7 +8,7 @@ class Token < ActiveRecord::Base create new token - token = Token.create( action: 'PasswordReset', user_id: user.id ) + token = Token.create(action: 'PasswordReset', user_id: user.id) returns @@ -34,7 +34,7 @@ returns check token - user = Token.check( action: 'PasswordReset', name: 'TheTokenItSelf' ) + user = Token.check(action: 'PasswordReset', name: 'TheTokenItSelf') returns @@ -42,10 +42,10 @@ returns =end - def self.check( data ) + def self.check(data) # fetch token - token = Token.find_by( action: data[:action], name: data[:name] ) + token = Token.find_by(action: data[:action], name: data[:name]) return if !token # check if token is still valid @@ -81,8 +81,7 @@ cleanup old token loop do self.name = SecureRandom.hex(30) - - break if !Token.exists?( name: name ) + break if !Token.exists?(name: name) end end end diff --git a/app/models/user.rb b/app/models/user.rb index 34fed0745..347d73e64 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -375,7 +375,7 @@ returns =begin -reset reset password with token and set new password +reset password with token and set new password result = User.password_reset_via_token(token,password) @@ -423,6 +423,63 @@ returns =begin +generate new token for signup + + result = User.signup_new_token(user) # or email + +returns + + result = { + token: token, + user: user, + } + +=end + + def self.signup_new_token(user) + return if !user + return if !user.email + + # generate token + token = Token.create(action: 'Signup', user_id: user.id) + + { + token: token, + user: user, + } + end + +=begin + +verify signup with token + + result = User.signup_verify_via_token(token, user) + +returns + + result = user_model # user_model if token was verified + +=end + + def self.signup_verify_via_token(token, user = nil) + + # check token + local_user = Token.check(action: 'Signup', name: token) + return if !local_user + + # if requested user is different to current user + return if user && local_user.id != user.id + + # set verified + local_user.update_attributes(verified: true) + + # delete token + Token.find_by(action: 'Signup', name: token).destroy + local_user + end + +=begin + merge two users to one user = User.find(123) diff --git a/config/routes/user.rb b/config/routes/user.rb index fe4eacb89..429c798a7 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -22,4 +22,7 @@ Zammad::Application.routes.draw do match api_path + '/users/:id', to: 'users#update', via: :put match api_path + '/users/image/:hash', to: 'users#image', via: :get + match api_path + '/users/email_verify', to: 'users#email_verify', via: :post + match api_path + '/users/email_verify_send', to: 'users#email_verify_send', via: :post + end diff --git a/test/browser/signup_password_change_and_reset_test.rb b/test/browser/signup_password_change_and_reset_test.rb index 68d884f33..eda23c9f4 100644 --- a/test/browser/signup_password_change_and_reset_test.rb +++ b/test/browser/signup_password_change_and_reset_test.rb @@ -3,11 +3,11 @@ require 'browser_test_helper' class SignupPasswordChangeAndResetTest < TestCase def test_signup - signup_user_email = 'signup-test-' + rand(999_999).to_s + '@example.com' + signup_user_email = "signup-test-#{rand(999_999)}@example.com" @browser = browser_instance - location( url: browser_url ) - click( css: 'a[href="#signup"]' ) - exists( css: '.signup' ) + location(url: browser_url) + click(css: 'a[href="#signup"]') + exists(css: '.signup') # signup set( @@ -30,10 +30,10 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_confirm"]', value: 'some-pass', ) - click( css: 'button.js-submit' ) + click(css: 'button.js-submit') sleep 5 - exists_not( css: '.signup' ) + exists_not(css: '.signup') match( css: '.user-menu .user a', @@ -41,11 +41,56 @@ class SignupPasswordChangeAndResetTest < TestCase attribute: 'title', ) + # check email verify + location(url: "#{browser_url}#email_verify/not_existing") + watch_for( + css: '#content', + value: 'Unable to verify email', + ) + logout() + + login( + username: signup_user_email, + password: 'some-pass', + url: "#{browser_url}#email_verify/not_existing2", + ) + watch_for( + css: '#content', + value: 'Unable to verify email', + ) + execute( + js: 'App.Event.trigger("user_signup_verify", App.Session.get())', + ) + watch_for( + css: '.modal', + value: 'Account not verified', + ) + click(css: '.modal .js-submit') + execute( + js: 'App.Auth.logout()', + ) + sleep 6 + watch_for( + css: '#login', + ) + login( + username: signup_user_email, + password: 'some-pass', + ) + watch_for( + css: '#content', + value: 'Your email is verified', + ) + exists_not( + css: '.modal', + ) + sleep 2 + # change password - click( css: '.navbar-items-personal .user a' ) + click(css: '.navbar-items-personal .user a') sleep 1 - click( css: 'a[href="#profile"]' ) - click( css: 'a[href="#profile/password"]' ) + click(css: 'a[href="#profile"]') + click(css: 'a[href="#profile/password"]') set( css: 'input[name="password_old"]', value: 'nonexisiting', @@ -58,7 +103,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_new_confirm"]', value: 'some', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', @@ -73,7 +118,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_new_confirm"]', value: 'some2', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', value: 'passwords do not match', @@ -87,7 +132,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_new_confirm"]', value: 'some', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', @@ -102,7 +147,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_new_confirm"]', value: 'some-pass-new', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', @@ -117,7 +162,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_new_confirm"]', value: 'some-pass-new2', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', @@ -133,7 +178,7 @@ class SignupPasswordChangeAndResetTest < TestCase logout() # reset password (not possible) - location( url: browser_url + '/#password_reset_verify/not_existing_token' ) + location(url: browser_url + '/#password_reset_verify/not_existing_token') watch_for( css: 'body', @@ -147,7 +192,7 @@ class SignupPasswordChangeAndResetTest < TestCase url: browser_url, ) - location( url: browser_url + '/#password_reset' ) + location(url: browser_url + '/#password_reset') sleep 1 match_not( @@ -157,13 +202,13 @@ class SignupPasswordChangeAndResetTest < TestCase logout() # reset password (correct way) - click( css: 'a[href="#password_reset"]' ) + click(css: 'a[href="#password_reset"]') set( css: 'input[name="username"]', value: 'nonexisiting', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', value: 'address invalid', @@ -173,7 +218,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="username"]', value: signup_user_email, ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', value: 'sent password reset instructions', @@ -194,7 +239,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_confirm"]', value: 'some2', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', value: 'passwords do not match', @@ -208,7 +253,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_confirm"]', value: 'some', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', value: 'it must be at least', @@ -222,7 +267,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_confirm"]', value: 'some-pass-new', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', value: 'must contain at least 1 digit', @@ -236,7 +281,7 @@ class SignupPasswordChangeAndResetTest < TestCase css: 'input[name="password_confirm"]', value: 'some-pass-new2', ) - click( css: '.content .btn--primary' ) + click(css: '.content .btn--primary') watch_for( css: 'body', value: 'Your password has been changed',