Added email verify feature for self signup accounts.

This commit is contained in:
Martin Edenhofer 2016-06-01 16:58:11 +02:00
parent 2ac873886e
commit 5adba8c9d0
14 changed files with 405 additions and 50 deletions

View file

@ -223,7 +223,7 @@ class App.Controller extends Spine.Controller
# remember requested url # remember requested url
if !checkOnly if !checkOnly
location = window.location.hash 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) @Config.set('requested_url', location)
return false if checkOnly return false if checkOnly
@ -559,16 +559,20 @@ class App.Controller extends Spine.Controller
return if !@initLoadingDoneDelay return if !@initLoadingDoneDelay
@clearDelay(@initLoadingDoneDelay) @clearDelay(@initLoadingDoneDelay)
renderScreenSuccess: (data) ->
App.TaskManager.touch(@task_key) if @task_key
@html App.view('generic/error/success')(data)
renderScreenError: (data) -> renderScreenError: (data) ->
App.TaskManager.touch(@task_key) App.TaskManager.touch(@task_key) if @task_key
@html App.view('generic/error/generic')(data) @html App.view('generic/error/generic')(data)
renderScreenNotFound: (data) -> renderScreenNotFound: (data) ->
App.TaskManager.touch(@task_key) App.TaskManager.touch(@task_key) if @task_key
@html App.view('generic/error/not_found')(data) @html App.view('generic/error/not_found')(data)
renderScreenUnauthorized: (data) -> renderScreenUnauthorized: (data) ->
App.TaskManager.touch(@task_key) App.TaskManager.touch(@task_key) if @task_key
@html App.view('generic/error/unauthorized')(data) @html App.view('generic/error/unauthorized')(data)
locationVerify: (e) => locationVerify: (e) =>

View file

@ -28,5 +28,5 @@ class DefaultRouter extends App.Controller
@navigate '#dashboard', true @navigate '#dashboard', true
App.Config.set( '', DefaultRouter, 'Routes' ) App.Config.set('', DefaultRouter, 'Routes')
App.Config.set( '/', DefaultRouter, 'Routes' ) App.Config.set('/', DefaultRouter, 'Routes')

View file

@ -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')

View file

@ -136,4 +136,4 @@ class Index extends App.ControllerContent
600 600
) )
App.Config.set( 'login', Index, 'Routes' ) App.Config.set('login', Index, 'Routes')

View file

@ -64,8 +64,8 @@ class Index extends App.ControllerContent
if data.token && @Config.get('developer_mode') is true if data.token && @Config.get('developer_mode') is true
redirect = => redirect = =>
@navigate "#password_reset_verify/#{data.token}" @navigate "#password_reset_verify/#{data.token}"
@delay( redirect, 2000 ) @delay(redirect, 2000)
@render( sent: true ) @render(sent: true)
else else
@$('[name=username]').val('') @$('[name=username]').val('')
@ -75,7 +75,7 @@ class Index extends App.ControllerContent
) )
@formEnable( @el.find('.form-password') ) @formEnable( @el.find('.form-password') )
App.Config.set( 'password_reset', Index, 'Routes' ) App.Config.set('password_reset', Index, 'Routes')
class Verify extends App.ControllerContent class Verify extends App.ControllerContent
events: events:
@ -105,10 +105,10 @@ class Verify extends App.ControllerContent
url: @apiPath + '/users/password_reset_verify' url: @apiPath + '/users/password_reset_verify'
data: JSON.stringify(params) data: JSON.stringify(params)
processData: true processData: true
success: @render_change success: @renderChange
) )
render_change: (data) => renderChange: (data) =>
if data.message is 'ok' if data.message is 'ok'
configure_attributes = [ configure_attributes = [
{ name: 'password', display: 'Password', tag: 'input', type: 'password', limit: 100, null: false, class: 'input', }, { 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' url: @apiPath + '/users/password_reset_verify'
data: JSON.stringify(params) data: JSON.stringify(params)
processData: true processData: true
success: @render_changed success: @renderChanged
) )
render_changed: (data, status, xhr) => renderChanged: (data, status, xhr) =>
if data.message is 'ok' if data.message is 'ok'
App.Auth.login( App.Auth.login(
data: data:

View file

@ -49,6 +49,7 @@ class Widget extends App.Controller
.on 'blur.translation', '.translation', (e) -> .on 'blur.translation', '.translation', (e) ->
element = $(e.target) element = $(e.target)
source = element.attr('title') source = element.attr('title')
return if !source
# get new translation # get new translation
translation_new = element.text() translation_new = element.text()
@ -62,6 +63,7 @@ class Widget extends App.Controller
App.i18n.setMap(source, translation_new) App.i18n.setMap(source, translation_new)
# replace rest in page # replace rest in page
source = source.replace('\'', '\\\'')
$(".translation[title='#{source}']").text(translation_new) $(".translation[title='#{source}']").text(translation_new)
# update permanent translation mapString # update permanent translation mapString

View file

@ -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 += '<br><br>'
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')

View file

@ -1,4 +1,4 @@
<div class="fullscreenMessage"> <div class="fullscreenMessage">
<%- @Icon('diagonal-cross', 'icon-error') %> <%- @Icon('diagonal-cross', 'icon-error') %>
<h2><% if @status isnt undefined: %><%- @T('Status Code') %>: <%= @status %>. <% end %><%= @detail %></h2> <h2><% if @status isnt undefined: %><%- @T('Status Code') %>: <%= @status %>. <% end %><%- @T(@detail) %></h2>
</div> </div>

View file

@ -0,0 +1,4 @@
<div class="fullscreenMessage">
<%- @Icon('checkmark') %>
<h2><%- @T(@detail) %></h2>
</div>

View file

@ -73,6 +73,10 @@ class UsersController < ApplicationController
# if it's a signup, add user to customer role # if it's a signup, add user to customer role
if !current_user 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.updated_by_id = 1
user.created_by_id = 1 user.created_by_id = 1
@ -100,6 +104,9 @@ class UsersController < ApplicationController
user.role_ids = role_ids user.role_ids = role_ids
user.group_ids = group_ids user.group_ids = group_ids
# remember source (in case show email verify banner)
user.source = 'signup'
# else do assignment as defined # else do assignment as defined
else else
@ -150,14 +157,11 @@ class UsersController < ApplicationController
# send email verify # send email verify
if params[:signup] && !current_user if params[:signup] && !current_user
token = Token.create(action: 'EmailVerify', user_id: user.id) result = User.signup_new_token(user)
NotificationFactory::Mailer.notification( NotificationFactory::Mailer.notification(
template: 'signup', template: 'signup',
user: user, user: user,
objects: { objects: result
token: token,
user: user,
}
) )
end end
user_new = User.find(user.id).attributes_with_associations user_new = User.find(user.id).attributes_with_associations
@ -393,6 +397,106 @@ class UsersController < ApplicationController
=begin =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: Resource:
POST /api/v1/users/password_reset POST /api/v1/users/password_reset

View file

@ -8,7 +8,7 @@ class Token < ActiveRecord::Base
create new token create new token
token = Token.create( action: 'PasswordReset', user_id: user.id ) token = Token.create(action: 'PasswordReset', user_id: user.id)
returns returns
@ -34,7 +34,7 @@ returns
check token check token
user = Token.check( action: 'PasswordReset', name: 'TheTokenItSelf' ) user = Token.check(action: 'PasswordReset', name: 'TheTokenItSelf')
returns returns
@ -42,10 +42,10 @@ returns
=end =end
def self.check( data ) def self.check(data)
# fetch token # 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 return if !token
# check if token is still valid # check if token is still valid
@ -81,8 +81,7 @@ cleanup old token
loop do loop do
self.name = SecureRandom.hex(30) self.name = SecureRandom.hex(30)
break if !Token.exists?(name: name)
break if !Token.exists?( name: name )
end end
end end
end end

View file

@ -375,7 +375,7 @@ returns
=begin =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) result = User.password_reset_via_token(token,password)
@ -423,6 +423,63 @@ returns
=begin =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 merge two users to one
user = User.find(123) user = User.find(123)

View file

@ -22,4 +22,7 @@ Zammad::Application.routes.draw do
match api_path + '/users/:id', to: 'users#update', via: :put 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/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 end

View file

@ -3,11 +3,11 @@ require 'browser_test_helper'
class SignupPasswordChangeAndResetTest < TestCase class SignupPasswordChangeAndResetTest < TestCase
def test_signup 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 @browser = browser_instance
location( url: browser_url ) location(url: browser_url)
click( css: 'a[href="#signup"]' ) click(css: 'a[href="#signup"]')
exists( css: '.signup' ) exists(css: '.signup')
# signup # signup
set( set(
@ -30,10 +30,10 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_confirm"]', css: 'input[name="password_confirm"]',
value: 'some-pass', value: 'some-pass',
) )
click( css: 'button.js-submit' ) click(css: 'button.js-submit')
sleep 5 sleep 5
exists_not( css: '.signup' ) exists_not(css: '.signup')
match( match(
css: '.user-menu .user a', css: '.user-menu .user a',
@ -41,11 +41,56 @@ class SignupPasswordChangeAndResetTest < TestCase
attribute: 'title', 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 # change password
click( css: '.navbar-items-personal .user a' ) click(css: '.navbar-items-personal .user a')
sleep 1 sleep 1
click( css: 'a[href="#profile"]' ) click(css: 'a[href="#profile"]')
click( css: 'a[href="#profile/password"]' ) click(css: 'a[href="#profile/password"]')
set( set(
css: 'input[name="password_old"]', css: 'input[name="password_old"]',
value: 'nonexisiting', value: 'nonexisiting',
@ -58,7 +103,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_new_confirm"]', css: 'input[name="password_new_confirm"]',
value: 'some', value: 'some',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
@ -73,7 +118,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_new_confirm"]', css: 'input[name="password_new_confirm"]',
value: 'some2', value: 'some2',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
value: 'passwords do not match', value: 'passwords do not match',
@ -87,7 +132,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_new_confirm"]', css: 'input[name="password_new_confirm"]',
value: 'some', value: 'some',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
@ -102,7 +147,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_new_confirm"]', css: 'input[name="password_new_confirm"]',
value: 'some-pass-new', value: 'some-pass-new',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
@ -117,7 +162,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_new_confirm"]', css: 'input[name="password_new_confirm"]',
value: 'some-pass-new2', value: 'some-pass-new2',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
@ -133,7 +178,7 @@ class SignupPasswordChangeAndResetTest < TestCase
logout() logout()
# reset password (not possible) # 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( watch_for(
css: 'body', css: 'body',
@ -147,7 +192,7 @@ class SignupPasswordChangeAndResetTest < TestCase
url: browser_url, url: browser_url,
) )
location( url: browser_url + '/#password_reset' ) location(url: browser_url + '/#password_reset')
sleep 1 sleep 1
match_not( match_not(
@ -157,13 +202,13 @@ class SignupPasswordChangeAndResetTest < TestCase
logout() logout()
# reset password (correct way) # reset password (correct way)
click( css: 'a[href="#password_reset"]' ) click(css: 'a[href="#password_reset"]')
set( set(
css: 'input[name="username"]', css: 'input[name="username"]',
value: 'nonexisiting', value: 'nonexisiting',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
value: 'address invalid', value: 'address invalid',
@ -173,7 +218,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="username"]', css: 'input[name="username"]',
value: signup_user_email, value: signup_user_email,
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
value: 'sent password reset instructions', value: 'sent password reset instructions',
@ -194,7 +239,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_confirm"]', css: 'input[name="password_confirm"]',
value: 'some2', value: 'some2',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
value: 'passwords do not match', value: 'passwords do not match',
@ -208,7 +253,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_confirm"]', css: 'input[name="password_confirm"]',
value: 'some', value: 'some',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
value: 'it must be at least', value: 'it must be at least',
@ -222,7 +267,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_confirm"]', css: 'input[name="password_confirm"]',
value: 'some-pass-new', value: 'some-pass-new',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
value: 'must contain at least 1 digit', value: 'must contain at least 1 digit',
@ -236,7 +281,7 @@ class SignupPasswordChangeAndResetTest < TestCase
css: 'input[name="password_confirm"]', css: 'input[name="password_confirm"]',
value: 'some-pass-new2', value: 'some-pass-new2',
) )
click( css: '.content .btn--primary' ) click(css: '.content .btn--primary')
watch_for( watch_for(
css: 'body', css: 'body',
value: 'Your password has been changed', value: 'Your password has been changed',