diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0fe78e959..99741dc47 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ before_script: - which ruby - env - test -n "$RNAME" && script/build/test_db_config.sh - - test -n "$RNAME" && bundle install + - test -n "$RNAME" && bundle install --jobs 8 stages: - pre @@ -229,7 +229,7 @@ test:integration:slack: - rake db:create - rake db:migrate - echo "gem 'slack-api'" >> Gemfile.local - - bundle install + - bundle install --jobs 8 - ruby -I test test/integration/slack_test.rb - rake db:drop @@ -281,6 +281,7 @@ test:integration:es_mysql: - ruby -I test/ test/integration/elasticsearch_test.rb - ruby -I test/ test/controllers/search_controller_test.rb - ruby -I test/ test/integration/report_test.rb + - ruby -I test/ test/controllers/form_controller_test.rb - rake db:drop test:integration:es_postgresql: @@ -297,6 +298,7 @@ test:integration:es_postgresql: - ruby -I test/ test/integration/elasticsearch_test.rb - ruby -I test/ test/controllers/search_controller_test.rb - ruby -I test/ test/integration/report_test.rb + - ruby -I test/ test/controllers/form_controller_test.rb - rake db:drop test:integration:zendesk_mysql: @@ -427,7 +429,7 @@ test:browser:integration:api_client_ruby: - script/build/test_startup.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 1 - git clone git@github.com:zammad/zammad-api-client-ruby.git || script/build/test_shutdown.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 1 1 - cd zammad-api-client-ruby - - bundle install + - bundle install --jobs 8 - export TEST_URL=http://$IP:$BROWSER_PORT - rspec || (cd .. && script/build/test_shutdown.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 1 1) - cd .. && script/build/test_shutdown.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 0 1 diff --git a/.travis.yml b/.travis.yml index c8153f76e..510217e74 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ notifications: env: - DB=mysql - DB=postgresql + - BUNDLE_JOBS=8 addons: postgresql: "9.4" apt: diff --git a/Gemfile b/Gemfile index f38dbef72..2695e13bb 100644 --- a/Gemfile +++ b/Gemfile @@ -45,7 +45,7 @@ gem 'twitter' gem 'telegramAPI' gem 'koala' gem 'mail' -gem 'email_verifier' +gem 'valid_email2' gem 'htmlentities' gem 'mime-types' diff --git a/Gemfile.lock b/Gemfile.lock index 1632ea661..5b161264b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,7 +93,6 @@ GEM delayed_job (>= 3.0, < 5) diff-lcs (1.2.5) diffy (3.1.0) - dnsruby (1.59.3) docile (1.1.5) domain_name (0.5.20170404) unf (>= 0.0.5, < 1.0.0) @@ -107,8 +106,6 @@ GEM em-websocket (0.5.1) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) - email_verifier (0.1.0) - dnsruby (>= 1.5) equalizer (0.0.10) erubis (2.7.0) eventmachine (1.2.3) @@ -403,6 +400,9 @@ GEM unicorn (5.2.0) kgio (~> 2.6) raindrops (~> 0.7) + valid_email2 (1.2.17) + activemodel (>= 3.2) + mail (~> 2.5) webmock (2.3.2) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -439,7 +439,6 @@ DEPENDENCIES doorkeeper eco em-websocket - email_verifier eventmachine execjs factory_girl_rails @@ -491,6 +490,7 @@ DEPENDENCIES twitter uglifier unicorn + valid_email2 webmock writeexcel zendesk_api diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index c22b4814d..99a71620e 100644 --- a/app/assets/javascripts/app/controllers/_channel/email.coffee +++ b/app/assets/javascripts/app/controllers/_channel/email.coffee @@ -531,8 +531,8 @@ class App.ChannelEmailAccountWizard extends App.WizardModal # base configureAttributesBase = [ - { name: 'realname', display: 'Organization & Department Name', tag: 'input', type: 'text', limit: 160, null: false, placeholder: 'Organization Support', autocomplete: 'new-password' }, - { name: 'email', display: 'Email', tag: 'input', type: 'email', limit: 120, null: false, placeholder: 'support@example.com', autocapitalize: false, autocomplete: 'new-password' }, + { name: 'realname', display: 'Organization & Department Name', tag: 'input', type: 'text', limit: 160, null: false, placeholder: 'Organization Support', autocomplete: 'off' }, + { name: 'email', display: 'Email', tag: 'input', type: 'email', limit: 120, null: false, placeholder: 'support@example.com', autocapitalize: false, autocomplete: 'off' }, { name: 'password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true }, { name: 'group_id', display: 'Destination Group', tag: 'select', null: false, relation: 'Group', nulloption: true }, ] @@ -562,7 +562,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal configureAttributesInbound = [ { name: 'adapter', display: 'Type', tag: 'select', multiple: false, null: false, options: @channelDriver.email.inbound }, { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false }, - { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', }, + { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'off', }, { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true }, { name: 'options::ssl', display: 'SSL', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, default: true, translate: true, item_class: 'formGroup--halfSize' }, { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false, default: '993', item_class: 'formGroup--halfSize' }, @@ -616,7 +616,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal if adapter is 'smtp' configureAttributesOutbound = [ { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autofocus: true }, - { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', }, + { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'off', }, { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', single: true }, { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false }, ] @@ -930,7 +930,7 @@ class App.ChannelEmailNotificationWizard extends App.WizardModal if adapter is 'smtp' configureAttributesOutbound = [ { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autofocus: true }, - { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password' }, + { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'off' }, { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', single: true }, { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false }, ] diff --git a/app/assets/javascripts/app/controllers/_channel/form.coffee b/app/assets/javascripts/app/controllers/_channel/form.coffee index d9697b132..6955b3b60 100644 --- a/app/assets/javascripts/app/controllers/_channel/form.coffee +++ b/app/assets/javascripts/app/controllers/_channel/form.coffee @@ -3,12 +3,14 @@ class App.ChannelForm extends App.ControllerSubContent requiredPermission: 'admin.channel_formular' header: 'Form' events: - 'change form.js-params': 'updateParams' - 'keyup form.js-params': 'updateParams' + 'change form.js-paramsDesigner': 'updateParamsDesigner' + 'keyup form.js-paramsDesigner': 'updateParamsDesigner' 'change .js-formSetting input': 'toggleFormSetting' + 'change .js-paramsSetting select': 'updateGroup' elements: '.js-paramsBlock': 'paramsBlock' + '.js-paramsSetting': 'paramsSetting' '.js-formSetting input': 'formSetting' constructor: -> @@ -20,22 +22,38 @@ class App.ChannelForm extends App.ControllerSubContent render: => setting = App.Setting.get('form_ticket_create') - @html App.view('channel/form')( + + element = $(App.view('channel/form')( baseurl: window.location.origin formSetting: setting + )) + + group_id = App.Setting.get('form_ticket_create_group_id') + selection = App.UiElement.select.render( + name: 'group_id' + multiple: false + null: false + relation: 'Group' + nulloption: false + value: group_id + #class: 'form-control--small' ) + console.log('s', element.find('.js-groupSelector'), selection) + element.find('.js-groupSelector').html(selection) + + @html element @paramsBlock.each (i, block) -> hljs.highlightBlock block - @updateParams() + @updateParamsDesigner() - updateParams: -> + updateParamsDesigner: -> quote = (string) -> string = string.replace('\'', '\\\'') .replace(/\/g, '>') - params = @formParam(@$('.js-params')) + params = @formParam(@$('.js-paramsDesigner')) paramString = '' for key, value of params if value != '' @@ -63,4 +81,8 @@ class App.ChannelForm extends App.ControllerSubContent value = @formSetting.prop('checked') App.Setting.set('form_ticket_create', value) + updateGroup: => + value = @paramsSetting.find('[name=group_id]').val() + App.Setting.set('form_ticket_create_group_id', value) + App.Config.set('Form', { prio: 2000, name: 'Form', parent: '#channels', target: '#channels/form', controller: App.ChannelForm, permission: ['admin.formular'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.coffee index d523501f2..067618d68 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.coffee @@ -404,7 +404,7 @@ class App.TicketZoom extends App.Controller ) new App.TicketZoomOverviewNavigator( - el: elLocal.find('.overview-navigator') + el: elLocal.find('.js-overviewNavigatorContainer') ticket_id: @ticket_id overview_id: @overview_id ) @@ -412,13 +412,13 @@ class App.TicketZoom extends App.Controller new App.TicketZoomTitle( object_id: @ticket_id overview_id: @overview_id - el: elLocal.find('.ticket-title') + el: elLocal.find('.js-ticketTitleContainer') task_key: @task_key ) new App.TicketZoomMeta( object_id: @ticket_id - el: elLocal.find('.ticket-meta') + el: elLocal.find('.js-ticketMetaContainer') ) @attributeBar = new App.TicketZoomAttributeBar( @@ -445,7 +445,12 @@ class App.TicketZoom extends App.Controller ) @highligher = new App.TicketZoomHighlighter( - el: elLocal.find('.highlighter') + el: elLocal.find('.js-highlighterContainer') + ticket_id: @ticket_id + ) + + new App.TicketZoomSetting( + el: elLocal.find('.js-settingContainer') ticket_id: @ticket_id ) @@ -557,14 +562,16 @@ class App.TicketZoom extends App.Controller return if !@ticket currentStoreTicket = @ticket.attributes() delete currentStoreTicket.article + internal = @Config.get('ui_ticket_zoom_article_note_new_internal') currentStore = ticket: currentStoreTicket article: to: '' cc: '' + subject: '' type: 'note' body: '' - internal: 'true' + internal: internal in_reply_to: '' if @permissionCheck('ticket.customer') @@ -575,7 +582,7 @@ class App.TicketZoom extends App.Controller formCurrent: => currentParams = ticket: @formParam(@el.find('.edit')) - article: @formParam(@el.find('.article-add')) + article: @articleNew.params() # add attachments if exist attachmentCount = @$('.article-add .textBubble .attachments .attachment').length diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee index c42c5ba7a..60fd1b1bf 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee @@ -28,107 +28,9 @@ class App.TicketZoomArticleNew extends App.Controller constructor: -> super - # set possble article types - possibleArticleType = - note: true - phone: true - if @ticket && @ticket.create_article_type_id - articleTypeCreate = App.TicketArticleType.find(@ticket.create_article_type_id).name - if articleTypeCreate is 'twitter status' - possibleArticleType['twitter status'] = true - else if articleTypeCreate is 'twitter direct-message' - possibleArticleType['twitter direct-message'] = true - else if articleTypeCreate is 'email' - possibleArticleType['email'] = true - else if articleTypeCreate is 'facebook feed post' - possibleArticleType['facebook feed comment'] = true - else if articleTypeCreate is 'telegram personal-message' - possibleArticleType['telegram personal-message'] = true - if @ticket && @ticket.customer_id - customer = App.User.find(@ticket.customer_id) - if customer.email - possibleArticleType['email'] = true - - # gets referenced in @setArticleType @internalSelector = true @type = @defaults['type'] || 'note' - @articleTypes = [] - if possibleArticleType.note - internal = @Config.get('ui_ticket_zoom_article_new_internal') - - @articleTypes.push { - name: 'note' - icon: 'note' - attributes: [] - internal: internal, - features: ['attachment'] - } - if possibleArticleType.email - @articleTypes.push { - name: 'email' - icon: 'email' - attributes: ['to', 'cc'] - internal: false, - features: ['attachment'] - } - if possibleArticleType['facebook feed comment'] - @articleTypes.push { - name: 'facebook feed comment' - icon: 'facebook' - attributes: [] - internal: false, - features: [] - } - if possibleArticleType['twitter status'] - @articleTypes.push { - name: 'twitter status' - icon: 'twitter' - attributes: [] - internal: false, - features: ['body:limit', 'body:initials'] - maxTextLength: 140 - warningTextLength: 30 - } - if possibleArticleType['twitter direct-message'] - @articleTypes.push { - name: 'twitter direct-message' - icon: 'twitter' - attributes: ['to'] - internal: false, - features: ['body:limit', 'body:initials'] - maxTextLength: 10000 - warningTextLength: 500 - } - if possibleArticleType.phone - @articleTypes.push { - name: 'phone' - icon: 'phone' - attributes: [] - internal: false, - features: ['attachment'] - } - if possibleArticleType['telegram personal-message'] - @articleTypes.push { - name: 'telegram personal-message' - icon: 'telegram' - attributes: [] - internal: false, - features: ['attachment'] - maxTextLength: 10000 - warningTextLength: 5000 - } - - if @permissionCheck('ticket.customer') - @type = 'note' - @articleTypes = [ - { - name: 'note' - icon: 'note' - attributes: [] - internal: false, - features: ['attachment'] - }, - ] + @setPossibleArticleTypes() if @permissionCheck('ticket.customer') @internalSelector = false @@ -181,6 +83,114 @@ class App.TicketZoomArticleNew extends App.Controller @render() ) + setPossibleArticleTypes: => + possibleArticleType = + note: true + phone: true + if @ticket && @ticket.create_article_type_id + articleTypeCreate = App.TicketArticleType.find(@ticket.create_article_type_id).name + if articleTypeCreate is 'twitter status' + possibleArticleType['twitter status'] = true + else if articleTypeCreate is 'twitter direct-message' + possibleArticleType['twitter direct-message'] = true + else if articleTypeCreate is 'email' + possibleArticleType['email'] = true + else if articleTypeCreate is 'facebook feed post' + possibleArticleType['facebook feed comment'] = true + else if articleTypeCreate is 'telegram personal-message' + possibleArticleType['telegram personal-message'] = true + if @ticket && @ticket.customer_id + customer = App.User.find(@ticket.customer_id) + if customer.email + possibleArticleType['email'] = true + + # gets referenced in @setArticleType + @articleTypes = [] + if possibleArticleType.note + internal = @Config.get('ui_ticket_zoom_article_note_new_internal') + @articleTypes.push { + name: 'note' + icon: 'note' + attributes: [] + internal: internal, + features: ['attachment'] + } + if possibleArticleType.email + attributes = ['to', 'cc', 'subject'] + if !@Config.get('ui_ticket_zoom_article_email_subject') + attributes = ['to', 'cc'] + @articleTypes.push { + name: 'email' + icon: 'email' + attributes: attributes + internal: false, + features: ['attachment'] + } + if possibleArticleType['facebook feed comment'] + @articleTypes.push { + name: 'facebook feed comment' + icon: 'facebook' + attributes: [] + internal: false, + features: [] + } + if possibleArticleType['twitter status'] + attributes = ['body:limit', 'body:initials'] + if !@Config.get('ui_ticket_zoom_article_twitter_initials') + attributes = ['body:limit'] + @articleTypes.push { + name: 'twitter status' + icon: 'twitter' + attributes: [] + internal: false, + features: ['body:limit', 'body:initials'] + maxTextLength: 140 + warningTextLength: 30 + } + if possibleArticleType['twitter direct-message'] + attributes = ['body:limit', 'body:initials'] + if !@Config.get('ui_ticket_zoom_article_twitter_initials') + attributes = ['body:limit'] + @articleTypes.push { + name: 'twitter direct-message' + icon: 'twitter' + attributes: ['to'] + internal: false, + features: ['body:limit', 'body:initials'] + maxTextLength: 10000 + warningTextLength: 500 + } + if possibleArticleType.phone + @articleTypes.push { + name: 'phone' + icon: 'phone' + attributes: [] + internal: false, + features: ['attachment'] + } + if possibleArticleType['telegram personal-message'] + @articleTypes.push { + name: 'telegram personal-message' + icon: 'telegram' + attributes: [] + internal: false, + features: ['attachment'] + maxTextLength: 10000 + warningTextLength: 5000 + } + + if @permissionCheck('ticket.customer') + @type = 'note' + @articleTypes = [ + { + name: 'note' + icon: 'note' + attributes: [] + internal: false, + features: ['attachment'] + }, + ] + placeCaretAtEnd: (el) -> el.focus() if typeof window.getSelection isnt 'undefined' && typeof document.createRange isnt 'undefined' @@ -318,9 +328,6 @@ class App.TicketZoomArticleNew extends App.Controller params.form_id = @form_id params.content_type = 'text/html' - if !params['internal'] - params['internal'] = false - if @permissionCheck('ticket.customer') sender = App.TicketArticleSender.findByAttribute('name', 'Customer') type = App.TicketArticleType.findByAttribute('name', 'web') @@ -332,6 +339,11 @@ class App.TicketZoomArticleNew extends App.Controller params.sender_id = sender.id params.type_id = type.id + if params.internal + params.internal = true + else + params.internal = false + if params.type is 'twitter status' App.Utils.htmlRemoveRichtext(@$('[data-name=body]'), false) params.content_type = 'text/plain' @@ -478,6 +490,8 @@ class App.TicketZoomArticleNew extends App.Controller @articleNewEdit.attr('data-type', type) @$('.js-selectableTypes').addClass('hide').filter("[data-type='#{type}']").removeClass('hide') + @setPossibleArticleTypes() + # get config config = {} for articleTypeConfig in @articleTypes diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/higlighter.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/highlighter.coffee similarity index 100% rename from app/assets/javascripts/app/controllers/ticket_zoom/higlighter.coffee rename to app/assets/javascripts/app/controllers/ticket_zoom/highlighter.coffee diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/setting.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/setting.coffee new file mode 100644 index 000000000..1103f9e3c --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/setting.coffee @@ -0,0 +1,35 @@ +class App.TicketZoomSetting extends App.Controller + events: + 'click .js-setting': 'show' + + constructor: -> + super + return if !@permissionCheck('admin') + @render() + + render: -> + @html(App.view('ticket_zoom/setting')()) + + show: -> + new Modal() + +class Modal extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: false + head: 'Settings' + + constructor: -> + super + + render: => + super + + post: => + new App.SettingsArea( + area: 'UI::TicketZoom' + el: @el.find('.modal-body') + ) + + content: -> + App.view('generic/page_loading')() diff --git a/app/assets/javascripts/app/lib/app_post/i18n.coffee b/app/assets/javascripts/app/lib/app_post/i18n.coffee index 1b138bf4e..3dd198b70 100644 --- a/app/assets/javascripts/app/lib/app_post/i18n.coffee +++ b/app/assets/javascripts/app/lib/app_post/i18n.coffee @@ -96,12 +96,15 @@ class _i18nSingleton extends Spine.Module # prepare locale localeToSet = localeToSet.toLowerCase() + dirToSet = 'ltr' # check if locale exists localeFound = false locales = App.Locale.all() for locale in locales if locale.locale is localeToSet + localeToSet = locale.locale + dirToSet = locale.dir localeFound = true # try aliases @@ -109,6 +112,8 @@ class _i18nSingleton extends Spine.Module for locale in locales if locale.alias is localeToSet localeToSet = locale.locale + dirToSet = locale.dir + localeFound = true # if no locale and no alias was found, try to find correct one if !localeFound @@ -118,15 +123,9 @@ class _i18nSingleton extends Spine.Module for locale in locales if locale.alias is localeToSet localeToSet = locale.locale + dirToSet = locale.dir localeFound = true - # try to find by locale - if !localeFound - for locale in locales - if locale.locale is localeToSet - localeToSet = locale.locale - localeFound = true - # check if locale need to be changed return if localeToSet is @locale @@ -136,8 +135,9 @@ class _i18nSingleton extends Spine.Module # set if not translated should be logged @_notTranslatedLog = @notTranslatedFeatureEnabled(@locale) - # set lang attribute of html tag - $('html').prop('lang', @locale.substr(0, 2) ) + # set lang and dir attribute of html tag + $('html').prop('lang', localeToSet.substr(0, 2)) + $('html').prop('dir', dirToSet) @mapString = {} App.Ajax.request( diff --git a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee index ce1e3713e..905e02885 100644 --- a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee @@ -12,6 +12,7 @@ class App.SearchableSelect extends Spine.Controller 'mouseenter .js-back': 'highlightItem' 'shown.bs.dropdown': 'onDropdownShown' 'hidden.bs.dropdown': 'onDropdownHidden' + 'keyup .js-input': 'onKeyUp' elements: '.js-dropdown': 'dropdown' @@ -120,6 +121,10 @@ class App.SearchableSelect extends Spine.Controller $(document).off 'keydown.searchable_select' @isOpen = false + onKeyUp: => + return if @input.val().trim() isnt '' + @shadowInput.val('') + toggle: => @currentItem = null @$('[data-toggle="dropdown"]').dropdown('toggle') diff --git a/app/assets/javascripts/app/lib/base/jquery.textmodule.js b/app/assets/javascripts/app/lib/base/jquery.textmodule.js index 492229a00..6236f42d3 100644 --- a/app/assets/javascripts/app/lib/base/jquery.textmodule.js +++ b/app/assets/javascripts/app/lib/base/jquery.textmodule.js @@ -180,7 +180,7 @@ Plugin.prototype.renderBase = function() { this.$element.after('') this.$widget = this.$element.next() - this.$widget.on('click', 'li', $.proxy(this.onEntryClick, this)) + this.$widget.on('mousedown', 'li', $.proxy(this.onEntryClick, this)) this.$widget.on('mouseenter', 'li', $.proxy(this.onMouseEnter, this)) } @@ -313,6 +313,7 @@ } Plugin.prototype.onEntryClick = function(event) { + event.preventDefault() var id = $(event.target).data('id') this.take(id) } diff --git a/app/assets/javascripts/app/views/channel/form.jst.eco b/app/assets/javascripts/app/views/channel/form.jst.eco index 6f6e891f7..f0e412a8f 100644 --- a/app/assets/javascripts/app/views/channel/form.jst.eco +++ b/app/assets/javascripts/app/views/channel/form.jst.eco @@ -10,8 +10,20 @@

<%- @T('With form you can add a form to your web page which directly generates a ticket for you.') %>

+

<%- @T('Settings') %>

+
+
+
+
+ +
+
+
+
+
+

<%- @T('Designer') %>

-
+
diff --git a/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco b/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco index 2fb84f854..75c44a713 100644 --- a/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco +++ b/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco @@ -33,13 +33,13 @@
  • <%- @T('%s user to %s user', 'LDAP', 'Zammad') %> (<%= @countDone %>/<%= @job.result.sum %>):
      -
    • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %> +
    • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>, <%= @job.result.deactivated %> <%- @T('deactivated') %>
    <% if !_.isEmpty(@job.result.roles): %>
  • <%- @T('%s groups to %s roles assignments', 'LDAP', 'Zammad') %>:
      <% for role, result of @job.result.roles: %> -
    • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %> +
    • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>, <%= result.deactivated %> <%- @T('deactivated') %> <% end %>
    <% end %> diff --git a/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco b/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco index 2cac7c834..2b79071c1 100644 --- a/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco +++ b/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco @@ -1,14 +1,14 @@
    • <%- @T('%s user to %s user', 'LDAP', 'Zammad') %> (<%= @countDone %>):
        -
      • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %> +
      • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>, <%= @job.result.deactivated %> <%- @T('deactivated') %>
    • <% if !_.isEmpty(@job.result.roles): %>
    • <%- @T('%s groups to %s roles assignments', 'LDAP', 'Zammad') %>:
        <% for role, result of @job.result.roles: %> -
      • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %> +
      • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>, <%= result.deactivated %> <%- @T('deactivated') %> <% end %>
    • diff --git a/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco b/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco index 1918d3eed..5710a9ad7 100644 --- a/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco +++ b/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco @@ -19,7 +19,7 @@ <%- @T('Host') %> - +
@@ -70,7 +70,7 @@ <%- @T('Bind User') %> - + <%- @T('Bind Password') %> @@ -203,7 +203,7 @@ <%- @T('User filter') %> - + <%- @T('Users without assigned LDAP groups') %> diff --git a/app/assets/javascripts/app/views/ticket_zoom.jst.eco b/app/assets/javascripts/app/views/ticket_zoom.jst.eco index 36ef86765..89d8733a8 100644 --- a/app/assets/javascripts/app/views/ticket_zoom.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom.jst.eco @@ -1,21 +1,22 @@
<%- @C('ticket_hook') %> <%- @ticket.number %> -
-
-
+
+
+
-
-
+
+
+
-
-
+
+
diff --git a/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco index 4f160381a..7f7142818 100644 --- a/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco @@ -41,13 +41,19 @@
-
+
-
+
+
+
+
+ +
+
diff --git a/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco new file mode 100644 index 000000000..491eab3c3 --- /dev/null +++ b/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco @@ -0,0 +1,3 @@ +
+ <%- @Icon('cog', 'dropdown-icon') %> +
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco b/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco index 143de9e84..0f395dbf9 100644 --- a/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco +++ b/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco @@ -13,6 +13,8 @@ <%- @T('Agent') %> <%- @T('Time Units') %> <%- @T('Time Units Total') %> + <%- @T('Created at') %> + <%- @T('Closed at') %> <% for row in @rows: %> @@ -24,6 +26,8 @@ <%= row.agent %> <%= row.time_unit %> <%= row.ticket.time_unit %> + <%- @humanTime(row.ticket.created_at) %> + <%- @humanTime(row.ticket.close_at) %> <% end %> diff --git a/app/controllers/form_controller.rb b/app/controllers/form_controller.rb index 664f34f5d..cb3b68a9d 100644 --- a/app/controllers/form_controller.rb +++ b/app/controllers/form_controller.rb @@ -7,6 +7,8 @@ class FormController < ApplicationController def config return if !enabled? + return if !fingerprint_exists? + return if limit_reached? api_path = Rails.configuration.api_path http_type = Setting.get('http_type') @@ -17,6 +19,7 @@ class FormController < ApplicationController config = { enabled: Setting.get('form_ticket_create'), endpoint: endpoint, + token: token_gen(params[:fingerprint]) } if params[:test] && current_user && current_user.permissions?('admin.channel_formular') @@ -28,35 +31,35 @@ class FormController < ApplicationController def submit return if !enabled? + return if !fingerprint_exists? + return if !token_valid?(params[:token], params[:fingerprint]) + return if limit_reached? # validate input errors = {} - if !params[:name] || params[:name].empty? + if params[:name].blank? errors['name'] = 'required' end - if !params[:email] || params[:email].empty? + if params[:email].blank? errors['email'] = 'required' - end - if params[:email] !~ /@/ + elsif params[:email] !~ /@/ + errors['email'] = 'invalid' + elsif params[:email] =~ /(>|<|\||\!|"|§|'|\$|%|&|\(|\)|\?|\s|\.\.)/ errors['email'] = 'invalid' end - if params[:email] =~ /(>|<|\||\!|"|§|'|\$|%|&|\(|\)|\?|\s)/ - errors['email'] = 'invalid' - end - if !params[:title] || params[:title].empty? + if params[:title].blank? errors['title'] = 'required' end - if !params[:body] || params[:body].empty? + if params[:body].blank? errors['body'] = 'required' end # realtime verify - if !errors['email'] + if errors['email'].blank? begin - checker = EmailVerifier::Checker.new(params[:email]) - checker.connect - if !checker.verify - errors['email'] = "Unable to send to '#{params[:email]}'" + address = ValidEmail2::Address.new(params[:email]) + if !address || !address.valid? || !address.valid_mx? + errors['email'] = 'invalid' end rescue => e message = e.to_s @@ -69,7 +72,7 @@ class FormController < ApplicationController end end - if errors && !errors.empty? + if errors.present? render json: { errors: errors }, status: :ok @@ -86,7 +89,6 @@ class FormController < ApplicationController firstname: name, lastname: '', email: email, - password: '', active: true, role_ids: role_ids, updated_by_id: 1, @@ -97,10 +99,23 @@ class FormController < ApplicationController # set current user UserInfo.current_user_id = customer.id + group = Group.find_by(id: Setting.get('form_ticket_create_group_id')) + if !group + group = Group.where(active: true).first + if !group + group = Group.first + end + end ticket = Ticket.create!( - group_id: 1, + group_id: group.id, customer_id: customer.id, title: params[:title], + preferences: { + form: { + remote_ip: request.remote_ip, + fingerprint_md5: Digest::MD5.hexdigest(params[:fingerprint]), + } + } ) article = Ticket::Article.create!( ticket_id: ticket.id, @@ -138,6 +153,91 @@ class FormController < ApplicationController private + def token_gen(fingerprint) + crypt = ActiveSupport::MessageEncryptor.new(Setting.get('application_secret')) + fingerprint = "#{Base64.strict_encode64(Setting.get('fqdn'))}:#{Time.zone.now.to_i}:#{Base64.strict_encode64(fingerprint)}" + Base64.strict_encode64(crypt.encrypt_and_sign(fingerprint)) + end + + def token_valid?(token, fingerprint) + if token.blank? + Rails.logger.info 'No token for form!' + response_access_deny + return false + end + begin + crypt = ActiveSupport::MessageEncryptor.new(Setting.get('application_secret')) + result = crypt.decrypt_and_verify(Base64.decode64(token)) + rescue + Rails.logger.info 'Invalid token for form!' + response_access_deny + return false + end + if result.blank? + Rails.logger.info 'Invalid token for form!' + response_access_deny + return false + end + parts = result.split(/:/) + if parts.count != 3 + Rails.logger.info "Invalid token for form (need to have 3 parts, only #{parts.count} found)!" + response_access_deny + return false + end + fqdn_local = Base64.decode64(parts[0]) + if fqdn_local != Setting.get('fqdn') + Rails.logger.info "Invalid token for form (invalid fqdn found #{fqdn_local} != #{Setting.get('fqdn')})!" + response_access_deny + return false + end + fingerprint_local = Base64.decode64(parts[2]) + if fingerprint_local != fingerprint + Rails.logger.info "Invalid token for form (invalid fingerprint found #{fingerprint_local} != #{fingerprint})!" + response_access_deny + return false + end + if parts[1].to_i < (Time.zone.now.to_i - 60 * 60 * 24) + Rails.logger.info 'Invalid token for form (token expired})!' + response_access_deny + return false + end + true + end + + def limit_reached? + return false if !SearchIndexBackend.enabled? + + form_limit_by_ip_per_hour = Setting.get('form_ticket_create_by_ip_per_hour') || 20 + result = SearchIndexBackend.search("preferences.form.remote_ip:'#{request.remote_ip}' AND created_at:>now-1h", form_limit_by_ip_per_hour, 'Ticket') + if result.count >= form_limit_by_ip_per_hour.to_i + response_access_deny + return true + end + + form_limit_by_ip_per_day = Setting.get('form_ticket_create_by_ip_per_day') || 240 + result = SearchIndexBackend.search("preferences.form.remote_ip:'#{request.remote_ip}' AND created_at:>now-1d", form_limit_by_ip_per_day, 'Ticket') + if result.count >= form_limit_by_ip_per_day.to_i + response_access_deny + return true + end + + form_limit_per_day = Setting.get('form_ticket_create_per_day') || 5000 + result = SearchIndexBackend.search('preferences.form.remote_ip:* AND created_at:>now-1d', form_limit_per_day, 'Ticket') + if result.count >= form_limit_per_day.to_i + response_access_deny + return true + end + + false + end + + def fingerprint_exists? + return true if params[:fingerprint].present? && params[:fingerprint].length > 30 + Rails.logger.info 'No fingerprint given!' + response_access_deny + false + end + def enabled? return true if params[:test] && current_user && current_user.permissions?('admin.channel_formular') return true if Setting.get('form_ticket_create') diff --git a/app/controllers/time_accountings_controller.rb b/app/controllers/time_accountings_controller.rb index f2acb1a09..e258217a7 100644 --- a/app/controllers/time_accountings_controller.rb +++ b/app/controllers/time_accountings_controller.rb @@ -93,9 +93,84 @@ class TimeAccountingsController < ApplicationController name: 'Time Units Total', width: 10, }, + { + name: 'Created at', + width: 10, + }, + { + name: 'Closed at', + width: 10, + }, + { + name: 'Close Escalation At', + width: 10, + }, + { + name: 'Close In Min', + width: 10, + }, + { + name: 'Close Diff In Min', + width: 10, + }, + { + name: 'First Response At', + width: 10, + }, + { + name: 'First Response Escalation At', + width: 10, + }, + { + name: 'First Response In Min', + width: 10, + }, + { + name: 'First Response Diff In Min', + width: 10, + }, + { + name: 'Update Escalation At', + width: 10, + }, + { + name: 'Update In Min', + width: 10, + }, + { + name: 'Update Diff In Min', + width: 10, + }, + { + name: 'Last Contact At', + width: 10, + }, + { + name: 'Last Contact Agent At', + width: 10, + }, + { + name: 'Last Contact Customer At', + width: 10, + }, + { + name: 'Article Count', + width: 10, + }, + { + name: 'Escalation At', + width: 10, + }, ] result = [] results.each { |row| + row[:ticket].keys.each { |field| + next if row[:ticket][field].blank? + next if !row[:ticket][field].is_a?(ActiveSupport::TimeWithZone) + + row[:ticket][field] = row[:ticket][field].iso8601 + } + result_row = [ row[:ticket]['number'], row[:ticket]['title'], @@ -104,6 +179,23 @@ class TimeAccountingsController < ApplicationController row[:agent], row[:time_unit], row[:ticket]['time_unit'], + row[:ticket]['created_at'], + row[:ticket]['close_at'], + row[:ticket]['close_escalation_at'], + row[:ticket]['close_in_min'], + row[:ticket]['close_diff_in_min'], + row[:ticket]['first_response_at'], + row[:ticket]['first_response_escalation_at'], + row[:ticket]['first_response_in_min'], + row[:ticket]['first_response_diff_in_min'], + row[:ticket]['update_escalation_at'], + row[:ticket]['update_in_min'], + row[:ticket]['update_diff_in_min'], + row[:ticket]['last_contact_at'], + row[:ticket]['last_contact_agent_at'], + row[:ticket]['last_contact_customer_at'], + row[:ticket]['article_count'], + row[:ticket]['escalation_at'], ] result.push result_row } diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 129a524d7..01c8a502e 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -504,7 +504,7 @@ condition example query += "#{attribute} IN (?)" bind_params.push 1 else - query += "#{attribute} IS NOT NULL" + query += "#{attribute} IS NULL" end elsif selector['pre_condition'] == 'current_user.id' raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id @@ -518,7 +518,7 @@ condition example else # rubocop:disable Style/IfInsideElse if selector['value'].nil? - query += "#{attribute} IS NOT NULL" + query += "#{attribute} IS NULL" else query += "#{attribute} IN (?)" bind_params.push selector['value'] @@ -531,7 +531,7 @@ condition example query += "#{attribute} NOT IN (?)" bind_params.push 1 else - query += "#{attribute} IS NULL" + query += "#{attribute} IS NOT NULL" end elsif selector['pre_condition'] == 'current_user.id' query += "#{attribute} NOT IN (?)" @@ -825,9 +825,11 @@ perform changes on ticket # loop protection / check if maximal count of trigger mail has reached map = { + 10 => 10, 30 => 15, 60 => 25, 180 => 50, + 600 => 100, } skip = false map.each { |minutes, count| @@ -843,18 +845,20 @@ perform changes on ticket } next if skip map = { - 1 => 150, - 3 => 250, - 6 => 450, + 10 => 30, + 30 => 60, + 60 => 120, + 180 => 240, + 600 => 360, } skip = false - map.each { |hours, count| + map.each { |minutes, count| already_sent = Ticket::Article.where( sender: Ticket::Article::Sender.find_by(name: 'System'), type: Ticket::Article::Type.find_by(name: 'email'), - ).where("ticket_articles.created_at > ? AND ticket_articles.to LIKE '%#{recipient_email.strip}%'", Time.zone.now - hours.hours).count + ).where("ticket_articles.created_at > ? AND ticket_articles.to LIKE '%#{recipient_email.strip}%'", Time.zone.now - minutes.minutes).count next if already_sent < count - logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{hours} hour(s) (loop protection)" + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{minutes} minutes (loop protection)" skip = true break } diff --git a/app/models/translation.rb b/app/models/translation.rb index 81744f445..9f536b32d 100644 --- a/app/models/translation.rb +++ b/app/models/translation.rb @@ -1,4 +1,5 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +require 'csv' class Translation < ApplicationModel before_create :set_initial @@ -212,7 +213,7 @@ translate strings in ruby context, e. g. for notifications =begin -load locales from local +load translations from local all: @@ -282,6 +283,65 @@ all: true end +=begin + +load translations from csv file + +all: + + Translation.load_from_csv + + or + + Translation.load_from_csv(locale, file_location, file_charset) # e. g. 'en-us' or 'de-de' and /path/to/translation_list.csv + + e. g. + + Translation.load_from_csv('he-il', '/Users/me/Downloads/Hebrew_translation_list-1.csv', 'Windows-1255') + +Get source file at https://i18n.zammad.com/api/v1/translations_empty_translation_list + +=end + + def self.load_from_csv(locale_name, location, charset = 'UTF8') + locale = Locale.find_by(locale: locale_name) + if !locale + raise "No such locale: #{locale_name}" + end + + if !::File.exist?(location) + raise "No such file: #{location}" + end + + content = ::File.open(location, "r:#{charset}").read + params = { + col_sep: ',', + } + rows = ::CSV.parse(content, params) + header = rows.shift + + translation_raw = [] + rows.each { |row| + raise "Can't import translation, source is missing" if row[0].blank? + if row[1].blank? + warn "Skipped #{row[0]}, because translation is blank" + next + end + raise "Can't import translation, format is missing" if row[2].blank? + raise "Can't import translation, format is invalid (#{row[2]})" if row[2] !~ /^(time|string)$/ + item = { + 'locale' => locale.locale, + 'source' => row[0], + 'target' => row[1], + 'target_initial' => '', + 'format' => row[2], + } + translation_raw.push item + } + to_database(locale.name, translation_raw) + true + end + private_class_method def self.to_database(locale, data) translations = Translation.where(locale: locale).all ActiveRecord::Base.transaction do diff --git a/app/models/user_device.rb b/app/models/user_device.rb index 15d7b3698..444703d15 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -21,8 +21,9 @@ store new device for user if device not already known def self.add(user_agent, ip, user_id, fingerprint, type) - # since gem browser 2 is not handling nil for user_agent, set it to '' - user_agent ||= '' + if user_agent.blank? + user_agent = 'unknown' + end # get location info location_details = Service::GeoIp.location(ip) @@ -60,23 +61,26 @@ store new device for user if device not already known end # get browser details - browser = Browser.new(user_agent, accept_language: 'en-us') - browser = { - plattform: browser.platform.to_s.camelize, - name: browser.name, - version: browser.version, - full_version: browser.full_version, - } + browser = {} + if user_agent != 'unknown' + browser = Browser.new(user_agent, accept_language: 'en-us') + browser = { + plattform: browser.platform.to_s.camelize, + name: browser.name, + version: browser.version, + full_version: browser.full_version, + } + end # generate device name if browser[:name] == 'Generic Browser' browser[:name] = user_agent end name = '' - if browser[:plattform] && browser[:plattform] != 'Other' + if browser[:plattform].present? && browser[:plattform] != 'Other' name = browser[:plattform] end - if browser[:name] && browser[:name] != 'Other' + if browser[:name].present? && browser[:name] != 'Other' if name.present? name += ', ' end @@ -84,7 +88,7 @@ store new device for user if device not already known end # if not identified, use user agent - if !name || name == '' || name == 'Other, Other' || name == 'Other' + if name.blank? || name == 'Other, Other' || name == 'Other' name = user_agent browser[:name] = user_agent end @@ -103,7 +107,7 @@ store new device for user if device not already known end # create new device - user_device = create( + user_device = create!( user_id: user_id, name: name, os: browser[:plattform], diff --git a/bin/setup b/bin/setup index 00ce4d30a..016034e94 100755 --- a/bin/setup +++ b/bin/setup @@ -10,7 +10,7 @@ Dir.chdir APP_ROOT do puts '== Installing dependencies ==' system 'gem install bundler --conservative' - system 'bundle check || bundle install' + system 'bundle check || bundle install --jobs 8' # puts "\n== Copying sample files ==" # unless File.exist?("config/database.yml") diff --git a/config/initializers/html_sanitizer.rb b/config/initializers/html_sanitizer.rb index b1d4a5b9c..387fd5fdd 100644 --- a/config/initializers/html_sanitizer.rb +++ b/config/initializers/html_sanitizer.rb @@ -40,6 +40,7 @@ Rails.application.config.html_sanitizer_attributes_whitelist = { 'table' => %w(align bgcolor border cellpadding cellspacing frame rules sortable summary width style), 'td' => %w(abbr align axis colspan headers rowspan valign width style), 'th' => %w(abbr align axis colspan headers rowspan scope sorted valign width style), + 'tr' => %w(width style), 'ul' => %w(type), 'q' => %w(cite), 'span' => %w(style), diff --git a/config/routes/form.rb b/config/routes/form.rb index 70fac4fde..b9c6974eb 100644 --- a/config/routes/form.rb +++ b/config/routes/form.rb @@ -3,6 +3,6 @@ Zammad::Application.routes.draw do # forms match api_path + '/form_submit', to: 'form#submit', via: :post - match api_path + '/form_config', to: 'form#config', via: :get + match api_path + '/form_config', to: 'form#config', via: :post end diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb index bf8a7b719..6e4e773c1 100644 --- a/db/migrate/20120101000001_create_base.rb +++ b/db/migrate/20120101000001_create_base.rb @@ -209,6 +209,7 @@ class CreateBase < ActiveRecord::Migration t.string :locale, limit: 20, null: false t.string :alias, limit: 20, null: true t.string :name, limit: 255, null: false + t.string :dir, limit: 9, null: false, default: 'ltr' t.boolean :active, null: false, default: true t.timestamps limit: 3, null: false end diff --git a/db/migrate/20170619000001_tree_select.rb b/db/migrate/20170619000001_tree_select.rb index 6b75aa49d..69dddaad7 100644 --- a/db/migrate/20170619000001_tree_select.rb +++ b/db/migrate/20170619000001_tree_select.rb @@ -1,5 +1,9 @@ class TreeSelect < ActiveRecord::Migration def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + change_column :object_manager_attributes, :data_option, :text, limit: 800.kilobytes + 1, null: true change_column :object_manager_attributes, :data_option_new, :text, limit: 800.kilobytes + 1, null: true end diff --git a/db/migrate/20170626000001_locale_add_direction.rb b/db/migrate/20170626000001_locale_add_direction.rb new file mode 100644 index 000000000..66fd8583c --- /dev/null +++ b/db/migrate/20170626000001_locale_add_direction.rb @@ -0,0 +1,9 @@ +class LocaleAddDirection < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + add_column :locales, :dir, :string, limit: 9, null: false, default: 'ltr' + end +end diff --git a/db/migrate/20170628000001_form_group_selection.rb b/db/migrate/20170628000001_form_group_selection.rb new file mode 100644 index 000000000..a7a1b6c60 --- /dev/null +++ b/db/migrate/20170628000001_form_group_selection.rb @@ -0,0 +1,103 @@ +class FormGroupSelection < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + group = Group.where(active: true).first + if !group + group = Group.first + end + group_id = 1 + if group + group_id = group.id + end + Setting.create_if_not_exists( + title: 'Group selection for Ticket creation', + name: 'form_ticket_create_group_id', + area: 'Form::Base', + description: 'Defines if group of created tickets via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: group_id, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + + Setting.create_if_not_exists( + title: 'Limit tickets by ip per hour', + name: 'form_ticket_create_by_ip_per_hour', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per hour via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_hour', + tag: 'input', + }, + ], + }, + state: 20, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + Setting.create_if_not_exists( + title: 'Limit tickets by ip per day', + name: 'form_ticket_create_by_ip_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_day', + tag: 'input', + }, + ], + }, + state: 240, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + Setting.create_if_not_exists( + title: 'Limit tickets per day', + name: 'form_ticket_create_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_per_day', + tag: 'input', + }, + ], + }, + state: 5000, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + + end +end diff --git a/db/migrate/20170630000001_ticket_zoom_setting.rb b/db/migrate/20170630000001_ticket_zoom_setting.rb new file mode 100644 index 000000000..9966132fd --- /dev/null +++ b/db/migrate/20170630000001_ticket_zoom_setting.rb @@ -0,0 +1,99 @@ +class TicketZoomSetting < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + setting = Setting.find_by(name: 'ui_ticket_zoom_article_new_internal') + if setting + setting.title = 'Note - default visibility' + setting.name = 'ui_ticket_zoom_article_note_new_internal' + setting.description = 'Default visibility for new articles.' + setting.preferences[:prio] = 100 + setting.options[:form][0][:name] = 'ui_ticket_zoom_article_note_new_internal' + setting.save! + end + Setting.create_if_not_exists( + title: 'Note - default visibility', + name: 'ui_ticket_zoom_article_note_new_internal', + area: 'UI::TicketZoom', + description: 'Default visibility for new articles.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_note_new_internal', + tag: 'boolean', + translate: true, + options: { + true => 'internal', + false => 'public', + }, + }, + ], + }, + state: true, + preferences: { + prio: 100, + permission: ['admin.ui'], + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'Email - subject field', + name: 'ui_ticket_zoom_article_email_subject', + area: 'UI::TicketZoom', + description: 'Use subject field for emails. If disabled, the ticket title will be used as subject.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_subject', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 200, + permission: ['admin.ui'], + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'Twitter - tweet initials', + name: 'ui_ticket_zoom_article_twitter_initials', + area: 'UI::TicketZoom', + description: 'Add sender initials to end of a tweet.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_twitter_initials', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 300, + permission: ['admin.ui'], + }, + frontend: true + ) + end + +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 777dc1aa1..73b3b7797 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -546,18 +546,17 @@ Setting.create_if_not_exists( }, frontend: true ) - Setting.create_if_not_exists( - title: 'Define default visibility of new a new article', - name: 'ui_ticket_zoom_article_new_internal', + title: 'Note - default visibility', + name: 'ui_ticket_zoom_article_note_new_internal', area: 'UI::TicketZoom', - description: 'Set default visibility of new a new article.', + description: 'Default visibility for new note.', options: { form: [ { display: '', null: true, - name: 'ui_ticket_zoom_article_new_internal', + name: 'ui_ticket_zoom_article_note_new_internal', tag: 'boolean', translate: true, options: { @@ -569,7 +568,61 @@ Setting.create_if_not_exists( }, state: true, preferences: { - prio: 1, + prio: 100, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Email - subject field', + name: 'ui_ticket_zoom_article_email_subject', + area: 'UI::TicketZoom', + description: 'Use subject field for emails. If disabled, the ticket title will be used as subject.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_subject', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 200, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Twitter - tweet initials', + name: 'ui_ticket_zoom_article_twitter_initials', + area: 'UI::TicketZoom', + description: 'Add sender initials to end of a tweet.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_twitter_initials', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 300, permission: ['admin.ui'], }, frontend: true @@ -1489,6 +1542,101 @@ Setting.create_if_not_exists( frontend: false, ) +group = Group.where(active: true).first +if !group + group = Group.first +end +group_id = 1 +if group + group_id = group.id +end +Setting.create_if_not_exists( + title: 'Group selection for Ticket creation', + name: 'form_ticket_create_group_id', + area: 'Form::Base', + description: 'Defines if group of created tickets via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: group_id, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) + +Setting.create_if_not_exists( + title: 'Limit tickets by ip per hour', + name: 'form_ticket_create_by_ip_per_hour', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per hour via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_hour', + tag: 'input', + }, + ], + }, + state: 20, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Limit tickets by ip per day', + name: 'form_ticket_create_by_ip_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_day', + tag: 'input', + }, + ], + }, + state: 240, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Limit tickets per day', + name: 'form_ticket_create_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_per_day', + tag: 'input', + }, + ], + }, + state: 5000, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) + Setting.create_if_not_exists( title: 'Ticket Subject Size', name: 'ticket_subject_size', diff --git a/lib/email_helper/verify.rb b/lib/email_helper/verify.rb index c872cce61..729b98027 100644 --- a/lib/email_helper/verify.rb +++ b/lib/email_helper/verify.rb @@ -56,7 +56,7 @@ or def self.email(params) # send verify email - subject = if !params[:subject] || params[:subject].empty? + subject = if params[:subject].blank? '#' + rand(99_999_999_999).to_s else params[:subject] diff --git a/lib/import/base_resource.rb b/lib/import/base_resource.rb index 7b70d305a..70761c75b 100644 --- a/lib/import/base_resource.rb +++ b/lib/import/base_resource.rb @@ -16,7 +16,7 @@ module Import end def source - import_class_namespace + self.class.source end def remote_id(resource, *_args) @@ -57,6 +57,14 @@ module Import changes end + def self.source + import_class_namespace + end + + def self.import_class_namespace + @import_class_namespace ||= name.to_s.sub('Import::', '') + end + private def initialize_associations_states @@ -83,31 +91,35 @@ module Import @resource = lookup_existing(resource, *args) return false if !@resource - # delete since we have an update and - # the record is already created - resource.delete(:created_by_id) + # lock the current resource for write access + @resource.with_lock do - # store the current state of the associations - # from the resource hash because if we assign - # them to the instance some (e.g. has_many) - # will get stored even in the dry run :/ - store_associations(:after, resource) + # delete since we have an update and + # the record is already created + resource.delete(:created_by_id) - associations = tracked_associations - @resource.assign_attributes(resource.except(*associations)) + # store the current state of the associations + # from the resource hash because if we assign + # them to the instance some (e.g. has_many) + # will get stored even in the dry run :/ + store_associations(:after, resource) - # the return value here is kind of misleading - # and should not be trusted to indicate if a - # resource was actually updated. - # Use .action instead - return true if !attributes_changed? + associations = tracked_associations + @resource.assign_attributes(resource.except(*associations)) - @action = :updated + # the return value here is kind of misleading + # and should not be trusted to indicate if a + # resource was actually updated. + # Use .action instead + return true if !attributes_changed? - return true if @dry_run - @resource.assign_attributes(resource.slice(*associations)) - @resource.save! - true + @action = :updated + + return true if @dry_run + @resource.assign_attributes(resource.slice(*associations)) + @resource.save! + true + end end def lookup_existing(resource, *_args) @@ -214,11 +226,7 @@ module Import end def mapping_config(*_args) - import_class_namespace.gsub('::', '_').underscore + '_mapping' - end - - def import_class_namespace - self.class.name.to_s.sub('Import::', '') + self.class.import_class_namespace.gsub('::', '_').underscore + '_mapping' end def handle_args(_resource, *args) diff --git a/lib/import/ldap/user.rb b/lib/import/ldap/user.rb index a70f85c50..19eaba32f 100644 --- a/lib/import/ldap/user.rb +++ b/lib/import/ldap/user.rb @@ -6,6 +6,34 @@ module Import @remote_id end + def self.lost_map(found_remote_ids) + ExternalSync.joins('INNER JOIN users ON (users.id = external_syncs.o_id)') + .where( + source: source, + object: import_class.name, + users: { + active: true + } + ) + .pluck(:source_id, :o_id) + .to_h + .except(*found_remote_ids) + end + + def self.deactivate_lost(lost_ids) + # we need to update in slices since some DBs + # have a limit for IN length + lost_ids.each_slice(5000) do |slice| + + # we need to instanciate every entry and set + # the active state this way to send notifications + # to the client + ::User.where(id: slice).each do |user| + user.update_attribute(:active, false) + end + end + end + private def import(resource, *args) @@ -58,7 +86,7 @@ module Import return true if resource[:login].blank? # skip resource if only ignored attributes are set - ignored_attributes = %i(login dn created_by_id updated_by_id) + ignored_attributes = %i(login dn created_by_id updated_by_id active) !resource.except(*ignored_attributes).values.any?(&:present?) end @@ -181,6 +209,11 @@ module Import mapped[attribute] = mapped[attribute].downcase end + # we have to add the active state manually + # because otherwise disabled instances won't get + # re-activated if they should get synced again + mapped[:active] = true + mapped end diff --git a/lib/import/ldap/user_factory.rb b/lib/import/ldap/user_factory.rb index 8b6c80aba..3ed6f40ee 100644 --- a/lib/import/ldap/user_factory.rb +++ b/lib/import/ldap/user_factory.rb @@ -33,10 +33,14 @@ module Import relevant_attributes = config[:user_attributes].keys relevant_attributes.push('dn') + @found_lost_remote_ids = [] + @found_remote_ids = [] @ldap.search(config[:user_filter], attributes: relevant_attributes) do |entry| backend_instance = create_instance(entry, config, user_roles, signup_role_ids, kargs) post_import_hook(entry, backend_instance, config, user_roles, signup_role_ids, kargs) + track_found_remote_ids(backend_instance) + next if import_job.blank? import_job_count += 1 next if import_job_count < 100 @@ -47,6 +51,7 @@ module Import import_job_count = 0 end + handle_lost end def self.pre_import_hook(_records, *_args) @@ -77,18 +82,25 @@ module Import action = backend_instance.action + add_resource_role_ids_to_statistics(resource.role_ids, action) + + action + end + + def self.add_resource_role_ids_to_statistics(role_ids, action) + return if role_ids.blank? + known_actions = { - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } - if !@statistics[:role_ids] - @statistics[:role_ids] = {} - end + @statistics[:role_ids] ||= {} - resource.role_ids.each do |role_id| + role_ids.each do |role_id| next if !known_actions.key?(action) @@ -99,8 +111,6 @@ module Import @statistics[:role_ids][role_id][action] += 1 end - - action end def self.user_roles(ldap:, config:) @@ -111,6 +121,46 @@ module Import ldap_group = ::Ldap::Group.new(group_config, ldap: ldap) ldap_group.user_roles(config[:group_role_map]) end + + def self.track_found_remote_ids(backend_instance) + remote_id = backend_instance.remote_id(nil) + @deactivation_actions ||= %i(skipped failed) + if @deactivation_actions.include?(backend_instance.action) + @found_lost_remote_ids.push(remote_id) + else + @found_remote_ids.push(remote_id) + end + end + + def self.handle_lost + backend_class = backend_class(nil) + lost_map = backend_class.lost_map(@found_remote_ids) + + # disabled count is tracked as a separate number + # since they don't have to be in the sum (e.g. deleted in LDAP) + @statistics[:deactivated] = lost_map.size + + # skipped deactivated are those who + # were found, skipped and will get deactivated + skipped_deactivated = @found_lost_remote_ids & lost_map.keys + @statistics[:skipped] -= skipped_deactivated.size + + # loop over every lost user ID and add the + # deactivated count to the statistics + lost_ids = lost_map.values + + lost_ids.each do |user_id| + role_ids = ::User.joins(:roles) + .where(id: user_id) + .pluck(:'roles_users.role_id') + + add_resource_role_ids_to_statistics(role_ids, :deactivated) + end + + # deactivate entries only on live syncs + return if @dry_run + backend_class.deactivate_lost(lost_ids) + end end end end diff --git a/lib/import/model_resource.rb b/lib/import/model_resource.rb index 3313d2535..0f35aad00 100644 --- a/lib/import/model_resource.rb +++ b/lib/import/model_resource.rb @@ -2,11 +2,19 @@ module Import class ModelResource < Import::BaseResource def import_class - model_name.constantize + self.class.import_class end def model_name - @model_name ||= self.class.name.split('::').last + self.class.model_name + end + + def self.import_class + model_name.constantize + end + + def self.model_name + @model_name ||= name.split('::').last end private diff --git a/lib/import/otrs/requester.rb b/lib/import/otrs/requester.rb index c3dbbf049..ef504cf71 100644 --- a/lib/import/otrs/requester.rb +++ b/lib/import/otrs/requester.rb @@ -95,6 +95,8 @@ module Import def handle_response(response) encoded_body = Encode.conv('utf8', response.body.to_s) + # remove null bytes otherwise PostgreSQL will fail + encoded_body.gsub!('\u0000', '') JSON.parse(encoded_body) end diff --git a/lib/import/otrs/state_factory.rb b/lib/import/otrs/state_factory.rb index b86501824..dd40f914e 100644 --- a/lib/import/otrs/state_factory.rb +++ b/lib/import/otrs/state_factory.rb @@ -43,9 +43,7 @@ module Import return if !state state.default_create = true - state.callback_loop = true - - state.save + state.save! end def update_default_follow_up @@ -56,9 +54,7 @@ module Import return if !state state.default_follow_up = true - state.callback_loop = true - - state.save + state.save! end def update_ticket_attributes diff --git a/lib/import/statistical_factory.rb b/lib/import/statistical_factory.rb index 1b882d87c..695c78d4e 100644 --- a/lib/import/statistical_factory.rb +++ b/lib/import/statistical_factory.rb @@ -13,11 +13,12 @@ module Import def reset_statistics @statistics = { - skipped: 0, - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 0, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } end diff --git a/lib/ldap/group.rb b/lib/ldap/group.rb index bec592c70..ba60b8297 100644 --- a/lib/ldap/group.rb +++ b/lib/ldap/group.rb @@ -80,14 +80,14 @@ class Ldap filter ||= filter() result = {} - @ldap.search(filter, attributes: %w(dn member)) do |entry| - - members = entry[:member] - next if members.blank? + @ldap.search(filter, attributes: %w(dn member memberuid)) do |entry| roles = mapping[entry.dn.downcase] next if roles.blank? + members = group_user_dns(entry) + next if members.blank? + members.each do |user_dn| user_dn_key = user_dn.downcase @@ -133,5 +133,18 @@ class Ldap @uid_attribute = config[:uid_attribute] @filter = config[:filter] end + + def group_user_dns(entry) + return entry[:member] if entry[:member].present? + return if entry[:memberuid].blank? + + entry[:memberuid].collect do |uid| + dn = nil + @ldap.search("(uid=#{uid})", attributes: %w(dn)) do |user| + dn = user.dn + end + dn + end.compact + end end end diff --git a/lib/ldap/user.rb b/lib/ldap/user.rb index ca000dbfc..681fe9028 100644 --- a/lib/ldap/user.rb +++ b/lib/ldap/user.rb @@ -162,7 +162,7 @@ class Ldap # # @return [String, nil] The active or found filter or nil if none could be found. def filter - @filter ||= lookup_filter(['(&(objectClass=user)(samaccountname=*)(!(samaccountname=*$)))', '(objectClass=user)', '(objectClass=posixaccount)']) + @filter ||= lookup_filter(['(&(objectClass=user)(samaccountname=*)(!(samaccountname=*$)))', '(objectClass=user)', '(objectClass=posixaccount)', '(objectClass=person)']) end # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed. diff --git a/lib/search_index_backend.rb b/lib/search_index_backend.rb index 14bd49144..116b752e8 100644 --- a/lib/search_index_backend.rb +++ b/lib/search_index_backend.rb @@ -427,8 +427,7 @@ return true if backend is configured =end def self.enabled? - return if !Setting.get('es_url') - return if Setting.get('es_url').empty? + return false if Setting.get('es_url').blank? true end diff --git a/public/assets/chat/chat.coffee b/public/assets/chat/chat.coffee index 94a5b00ef..f9a7d216d 100644 --- a/public/assets/chat/chat.coffee +++ b/public/assets/chat/chat.coffee @@ -536,7 +536,7 @@ do($ = window.jQuery, window) -> @maybeAddTimestamp() # add message before message typing loader - if @el.find('.zammad-chat-message--typing').size() + if @el.find('.zammad-chat-message--typing').get(0) @lastAddedType = 'typing-placeholder' @el.find('.zammad-chat-message--typing').before messageElement else @@ -725,7 +725,7 @@ do($ = window.jQuery, window) -> @stopTypingId = setTimeout(@onAgentTypingEnd, 3000) # never display two typing indicators - return if @el.find('.zammad-chat-message--typing').size() + return if @el.find('.zammad-chat-message--typing').get(0) @maybeAddTimestamp() diff --git a/public/assets/chat/chat.js b/public/assets/chat/chat.js index 480f5a78d..3bdb1803c 100644 --- a/public/assets/chat/chat.js +++ b/public/assets/chat/chat.js @@ -1,64 +1,3 @@ -if (!window.zammadChatTemplates) { - window.zammadChatTemplates = {}; -} -window.zammadChatTemplates["agent"] = function (__obj) { - if (!__obj) __obj = {}; - var __out = [], __capture = function(callback) { - var out = __out, result; - __out = []; - callback.call(this); - result = __out.join(''); - __out = out; - return __safe(result); - }, __sanitize = function(value) { - if (value && value.ecoSafe) { - return value; - } else if (typeof value !== 'undefined' && value != null) { - return __escape(value); - } else { - return ''; - } - }, __safe, __objSafe = __obj.safe, __escape = __obj.escape; - __safe = __obj.safe = function(value) { - if (value && value.ecoSafe) { - return value; - } else { - if (!(typeof value !== 'undefined' && value != null)) value = ''; - var result = new String(value); - result.ecoSafe = true; - return result; - } - }; - if (!__escape) { - __escape = __obj.escape = function(value) { - return ('' + value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - }; - } - (function() { - (function() { - if (this.agent.avatar) { - __out.push('\n\n'); - } - - __out.push('\n\n '); - - __out.push(__sanitize(this.agent.name)); - - __out.push('\n'); - - }).call(this); - - }).call(__obj); - __obj.safe = __objSafe, __obj.escape = __escape; - return __out.join(''); -}; - var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, slice = [].slice, extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, @@ -823,7 +762,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); unreadClass: '' }); this.maybeAddTimestamp(); - if (this.el.find('.zammad-chat-message--typing').size()) { + if (this.el.find('.zammad-chat-message--typing').get(0)) { this.lastAddedType = 'typing-placeholder'; this.el.find('.zammad-chat-message--typing').before(messageElement); } else { @@ -1022,7 +961,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); clearTimeout(this.stopTypingId); } this.stopTypingId = setTimeout(this.onAgentTypingEnd, 3000); - if (this.el.find('.zammad-chat-message--typing').size()) { + if (this.el.find('.zammad-chat-message--typing').get(0)) { return; } this.maybeAddTimestamp(); @@ -1395,6 +1334,67 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); return window.ZammadChat = ZammadChat; })(window.jQuery, window); +if (!window.zammadChatTemplates) { + window.zammadChatTemplates = {}; +} +window.zammadChatTemplates["agent"] = function (__obj) { + if (!__obj) __obj = {}; + var __out = [], __capture = function(callback) { + var out = __out, result; + __out = []; + callback.call(this); + result = __out.join(''); + __out = out; + return __safe(result); + }, __sanitize = function(value) { + if (value && value.ecoSafe) { + return value; + } else if (typeof value !== 'undefined' && value != null) { + return __escape(value); + } else { + return ''; + } + }, __safe, __objSafe = __obj.safe, __escape = __obj.escape; + __safe = __obj.safe = function(value) { + if (value && value.ecoSafe) { + return value; + } else { + if (!(typeof value !== 'undefined' && value != null)) value = ''; + var result = new String(value); + result.ecoSafe = true; + return result; + } + }; + if (!__escape) { + __escape = __obj.escape = function(value) { + return ('' + value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + } + (function() { + (function() { + if (this.agent.avatar) { + __out.push('\n\n'); + } + + __out.push('\n\n '); + + __out.push(__sanitize(this.agent.name)); + + __out.push('\n'); + + }).call(this); + + }).call(__obj); + __obj.safe = __objSafe, __obj.escape = __escape; + return __out.join(''); +}; + /*! * ---------------------------------------------------------------------------- * "THE BEER-WARE LICENSE" (Revision 42): diff --git a/public/assets/chat/chat.min.js b/public/assets/chat/chat.min.js index eda8bf4bc..29472bc59 100644 --- a/public/assets/chat/chat.min.js +++ b/public/assets/chat/chat.min.js @@ -1,2 +1,2 @@ -window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){this.agent.avatar&&(s.push('\n\n')),s.push('\n\n '),s.push(n(this.agent.name)),s.push("\n")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")};var bind=function(t,e){return function(){return t.apply(e,arguments)}},slice=[].slice,extend=function(t,e){function s(){this.constructor=t}for(var n in e)hasProp.call(e,n)&&(t[n]=e[n]);return s.prototype=e.prototype,t.prototype=new s,t.__super__=e.prototype,t},hasProp={}.hasOwnProperty;!function(t,e){var s,n,i,o,a,r,h,l,d;return d=document.getElementsByTagName("script"),r=d[d.length-1],h=r.src.match(".*://([^:/]*).*")[1],l=r.src.match("(.*)://[^:/]*.*")[1],s=function(){function e(e){this.options=t.extend({},this.defaults,e),this.log=new i({debug:this.options.debug,logPrefix:this.options.logPrefix||this.logPrefix})}return e.prototype.defaults={debug:!1},e}(),i=function(){function e(e){this.log=bind(this.log,this),this.error=bind(this.error,this),this.notice=bind(this.notice,this),this.debug=bind(this.debug,this),this.options=t.extend({},this.defaults,e)}return e.prototype.defaults={debug:!1},e.prototype.debug=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.options.debug?this.log("debug",t):void 0},e.prototype.notice=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("notice",t)},e.prototype.error=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("error",t)},e.prototype.log=function(e,s){var n,i,o,a;if(s.unshift("||"),s.unshift(e),s.unshift(this.options.logPrefix),console.log.apply(console,s),this.options.debug){for(a="",n=0,o=s.length;o>n;n++)i=s[n],a+=" ",a+="object"==typeof i?JSON.stringify(i):i&&i.toString?i.toString():i;return t(".js-chatLogDisplay").prepend("
"+a+"
")}},e}(),o=function(t){function e(t){this.stop=bind(this.stop,this),this.start=bind(this.start,this),e.__super__.constructor.call(this,t)}return extend(e,t),e.prototype.timeoutStartedAt=null,e.prototype.logPrefix="timeout",e.prototype.defaults={debug:!1,timeout:4,timeoutIntervallCheck:.5},e.prototype.start=function(){var t,e;return this.stop(),e=new Date,t=function(t){return function(){var s;return s=new Date-new Date(e.getTime()+1e3*t.options.timeout*60),t.log.debug("Timeout check for "+t.options.timeout+" minutes (left "+s/1e3+" sec.)"),0>s?void 0:(t.stop(),t.options.callback())}}(this),this.log.debug("Start timeout in "+this.options.timeout+" minutes"),this.intervallId=setInterval(t,1e3*this.options.timeoutIntervallCheck*60)},e.prototype.stop=function(){return this.intervallId?(this.log.debug("Stop timeout of "+this.options.timeout+" minutes"),clearInterval(this.intervallId)):void 0},e}(s),n=function(t){function s(t){this.ping=bind(this.ping,this),this.send=bind(this.send,this),this.reconnect=bind(this.reconnect,this),this.close=bind(this.close,this),this.connect=bind(this.connect,this),this.set=bind(this.set,this),s.__super__.constructor.call(this,t)}return extend(s,t),s.prototype.logPrefix="io",s.prototype.set=function(t){var e,s,n;s=[];for(e in t)n=t[e],s.push(this.options[e]=n);return s},s.prototype.connect=function(){return this.log.debug("Connecting to "+this.options.host),this.ws=new e.WebSocket(""+this.options.host),this.ws.onopen=function(t){return function(e){return t.log.debug("onOpen",e),t.options.onOpen(e),t.ping()}}(this),this.ws.onmessage=function(t){return function(e){var s,n,i,o;for(o=JSON.parse(e.data),t.log.debug("onMessage",e.data),s=0,n=o.length;n>s;s++)i=o[s],"pong"===i.event&&t.ping();return t.options.onMessage?t.options.onMessage(o):void 0}}(this),this.ws.onclose=function(t){return function(e){if(t.log.debug("close websocket connection",e),t.pingDelayId&&clearTimeout(t.pingDelayId),t.manualClose){if(t.log.debug("manual close, onClose callback"),t.manualClose=!1,t.options.onClose)return t.options.onClose(e)}else if(t.log.debug("error close, onError callback"),t.options.onError)return t.options.onError("Connection lost...")}}(this),this.ws.onerror=function(t){return function(e){return t.log.debug("onError",e),t.options.onError?t.options.onError(e):void 0}}(this)},s.prototype.close=function(){return this.log.debug("close websocket manually"),this.manualClose=!0,this.ws.close()},s.prototype.reconnect=function(){return this.log.debug("reconnect"),this.close(),this.connect()},s.prototype.send=function(t,e){var s;return null==e&&(e={}),this.log.debug("send",t,e),s=JSON.stringify({event:t,data:e}),this.ws.send(s)},s.prototype.ping=function(){var t;return t=function(t){return function(){return t.send("ping")}}(this),this.pingDelayId=setTimeout(t,29e3)},s}(s),a=function(s){function i(s){return this.startTimeoutObservers=bind(this.startTimeoutObservers,this),this.onCssLoaded=bind(this.onCssLoaded,this),this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.setSessionId=bind(this.setSessionId,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.destroy=bind(this.destroy,this),this.onScrollHintClick=bind(this.onScrollHintClick,this),this.detectScrolledtoBottom=bind(this.detectScrolledtoBottom,this),this.onLeaveTemporary=bind(this.onLeaveTemporary,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onQueueScreen=bind(this.onQueueScreen,this),this.onWebSocketClose=bind(this.onWebSocketClose,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.close=bind(this.close,this),this.toggle=bind(this.toggle,this),this.sessionClose=bind(this.sessionClose,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.open=bind(this.open,this),this.renderMessage=bind(this.renderMessage,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onFocus=bind(this.onFocus,this),this.onInput=bind(this.onInput,this),this.onReopenSession=bind(this.onReopenSession,this),this.onError=bind(this.onError,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.render=bind(this.render,this),this.view=bind(this.view,this),this.T=bind(this.T,this),this.options=t.extend({},this.defaults,s),i.__super__.constructor.call(this,this.options),this.isFullscreen=e.matchMedia&&e.matchMedia("(max-width: 768px)").matches,this.scrollRoot=t(this.getScrollRoot()),t?e.WebSocket&&sessionStorage?this.options.chatId?(this.options.lang||(this.options.lang=t("html").attr("lang")),this.options.lang&&(this.translations[this.options.lang]||(this.log.debug("lang: No "+this.options.lang+" found, try first two letters"),this.options.lang=this.options.lang.replace(/-.+?$/,"")),this.log.debug("lang: "+this.options.lang)),this.options.host||this.detectHost(),this.loadCss(),this.io=new n(this.options),this.io.set({onOpen:this.render,onClose:this.onWebSocketClose,onMessage:this.onWebSocketMessage,onError:this.onError}),void this.io.connect()):(this.state="unsupported",void this.log.error("Chat: need chatId as option!")):(this.state="unsupported",void this.log.notice("Chat: Browser not supported!")):(this.state="unsupported",void this.log.notice("Chat: no jquery found!"))}return extend(i,s),i.prototype.defaults={chatId:void 0,show:!0,target:t("body"),host:"",debug:!1,flat:!1,lang:void 0,cssAutoload:!0,cssUrl:void 0,fontSize:void 0,buttonClass:"open-zammad-chat",inactiveClass:"is-inactive",title:"Chat with us!",scrollHint:"Scrolle nach unten um neue Nachrichten zu sehen",idleTimeout:6,idleTimeoutIntervallCheck:.5,inactiveTimeout:8,inactiveTimeoutIntervallCheck:.5,waitingListTimeout:4,waitingListTimeoutIntervallCheck:.5},i.prototype.logPrefix="chat",i.prototype._messageCount=0,i.prototype.isOpen=!1,i.prototype.blinkOnlineInterval=null,i.prototype.stopBlinOnlineStateTimeout=null,i.prototype.showTimeEveryXMinutes=2,i.prototype.lastTimestamp=null,i.prototype.lastAddedType=null,i.prototype.inputTimeout=null,i.prototype.isTyping=!1,i.prototype.state="offline",i.prototype.initialQueueDelay=1e4,i.prototype.translations={de:{"Chat with us!":"Chatte mit uns!","Scroll down to see new messages":"Scrolle nach unten um neue Nachrichten zu sehen",Online:"Online",Online:"Online",Offline:"Offline",Connecting:"Verbinden","Connection re-established":"Verbindung wiederhergestellt",Today:"Heute",Send:"Senden","Compose your message...":"Ihre Nachricht...","All colleagues are busy.":"Alle Kollegen sind belegt.","You are on waiting list position %s.":"Sie sind in der Warteliste an der Position %s.","Start new conversation":"Neue Konversation starten","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit %s geschlossen.","Since you didn't respond in the last %s minutes your conversation got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Es tut uns leid, es dauert länger als erwartet, um einen freien Platz zu erhalten. Bitte versuchen Sie es zu einem späteren Zeitpunkt noch einmal oder schicken Sie uns eine E-Mail. Vielen Dank!"},fr:{"Chat with us!":"Chattez avec nous!","Scroll down to see new messages":"Faites défiler pour lire les nouveaux messages",Online:"En-ligne",Online:"En-ligne",Offline:"Hors-ligne",Connecting:"Connexion en cours","Connection re-established":"Connexion rétablie",Today:"Aujourdhui",Send:"Envoyer","Compose your message...":"Composez votre message...","All colleagues are busy.":"Tous les collègues sont actuellement occupés.","You are on waiting list position %s.":"Vous êtes actuellement en %s position dans la file d'attente.","Start new conversation":"Démarrer une nouvelle conversation","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation avec %s va être fermée.","Since you didn't respond in the last %s minutes your conversation got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation va être fermée.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Nous sommes désolés, il faut plus de temps que prévu pour obtenir un emplacement vide. Veuillez réessayer ultérieurement ou nous envoyer un courriel. Je vous remercie!"},"zh-cn":{"Chat with us!":"发起即时对话!","Scroll down to see new messages":"向下滚动以查看新消息",Online:"在线",Online:"在线",Offline:"离线",Connecting:"连接中","Connection re-established":"正在重新建立连接",Today:"今天",Send:"发送","Compose your message...":"正在输入信息...","All colleagues are busy.":"所有工作人员都在忙碌中.","You are on waiting list position %s.":"您目前的等候位置是第 %s 位.","Start new conversation":"开始新的会话","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由于您超过 %s 分钟没有回复, 您与 %s 的会话已被关闭.","Since you didn't respond in the last %s minutes your conversation got closed.":"由于您超过 %s 分钟没有任何回复, 该对话已被关闭.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 目前需要等候更长的时间才能接入对话, 请稍后重试或向我们发送电子邮件. 谢谢!"},"zh-tw":{"Chat with us!":"開始即時對话!","Scroll down to see new messages":"向下滑動以查看新訊息",Online:"線上",Online:"線上",Offline:"离线",Connecting:"連線中","Connection re-established":"正在重新建立連線中",Today:"今天",Send:"發送","Compose your message...":"正在輸入訊息...","All colleagues are busy.":"所有服務人員都在忙碌中.","You are on waiting list position %s.":"你目前的等候位置是第 %s 順位.","Start new conversation":"開始新的對話","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由於你超過 %s 分鐘沒有回應, 你與 %s 的對話已被關閉.","Since you didn't respond in the last %s minutes your conversation got closed.":"由於你超過 %s 分鐘沒有任何回應, 該對話已被關閉.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 當前需要等候更長的時間方可排入對話程序, 請稍後重試或向我們寄送電子郵件. 謝謝!"}},i.prototype.sessionId=void 0,i.prototype.scrolledToBottom=!0,i.prototype.scrollSnapTolerance=10,i.prototype.T=function(){var t,e,s,n,i,o;if(i=arguments[0],s=2<=arguments.length?slice.call(arguments,1):[],this.options.lang&&"en"!==this.options.lang&&(this.translations[this.options.lang]?(o=this.translations[this.options.lang],o[i]||this.log.notice("Translation needed for '"+i+"'"),i=o[i]||i):this.log.notice("Translation '"+this.options.lang+"' needed!")),s)for(t=0,n=s.length;n>t;t++)e=s[t],i=i.replace(/%s/,e);return i},i.prototype.view=function(t){return function(s){return function(n){return n||(n={}),n.T=s.T,n.background=s.options.background,n.flat=s.options.flat,n.fontSize=s.options.fontSize,e.zammadChatTemplates[t](n)}}(this)},i.prototype.getScrollRoot=function(){var t,e,s;return"scrollingElement"in document?document.scrollingElement:(e=document.documentElement,s=e.scrollTop,e.scrollTop=s+1,t=e.scrollTop,e.scrollTop=s,t>s?e:document.body)},i.prototype.render=function(){return this.el&&t(".zammad-chat").get(0)||this.renderBase(),t("."+this.options.buttonClass).addClass(this.inactiveClass),this.setAgentOnlineState("online"),this.log.debug("widget rendered"),this.startTimeoutObservers(),this.idleTimeout.start(),this.sessionId=sessionStorage.getItem("sessionId"),this.send("chat_status_customer",{session_id:this.sessionId,url:e.location.href})},i.prototype.renderBase=function(){return this.el=t(this.view("chat")({title:this.options.title,scrollHint:this.options.scrollHint})),this.options.target.append(this.el),this.input=this.el.find(".zammad-chat-input"),this.el.find(".js-chat-open").click(this.open),this.el.find(".js-chat-toggle").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-body").on("scroll",this.detectScrolledtoBottom),this.el.find(".zammad-scroll-hint").click(this.onScrollHintClick),this.input.on({keydown:this.checkForEnter,input:this.onInput}),t(e).on("beforeunload",function(t){return function(){return t.onLeaveTemporary()}}(this)),t(e).bind("hashchange",function(t){return function(){return t.isOpen?void(t.sessionId&&t.send("chat_session_notice",{session_id:t.sessionId,message:e.location.href})):t.idleTimeout.start()}}(this)),this.isFullscreen?this.input.on({focus:this.onFocus,focusout:this.onFocusOut}):void 0},i.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},i.prototype.send=function(t,e){return null==e&&(e={}),e.chat_id=this.options.chatId,this.io.send(t,e)},i.prototype.onWebSocketMessage=function(t){var e,s,n;for(e=0,s=t.length;s>e;e++)switch(n=t[e],this.log.debug("ws:onmessage",n),n.event){case"chat_error":this.log.notice(n.data),n.data&&"chat_disabled"===n.data.state&&this.destroy({remove:!0});break;case"chat_session_message":if(n.data.self_written)return;this.receiveMessage(n.data);break;case"chat_session_typing":if(n.data.self_written)return;this.onAgentTypingStart();break;case"chat_session_start":this.onConnectionEstablished(n.data);break;case"chat_session_queue":this.onQueueScreen(n.data);break;case"chat_session_closed":this.onSessionClosed(n.data);break;case"chat_session_left":this.onSessionClosed(n.data);break;case"chat_status_customer":switch(n.data.state){case"online":this.sessionId=void 0,!this.options.cssAutoload||this.cssLoaded?this.onReady():this.socketReady=!0;break;case"offline":this.onError("Zammad Chat: No agent online");break;case"chat_disabled":this.onError("Zammad Chat: Chat is disabled");break;case"no_seats_available":this.onError("Zammad Chat: Too many clients in queue. Clients in queue: "+n.data.queue);break;case"reconnect":this.onReopenSession(n.data)}}},i.prototype.onReady=function(){return this.log.debug("widget ready for use"),t("."+this.options.buttonClass).click(this.open).removeClass(this.inactiveClass),this.options.show?this.show():void 0},i.prototype.onError=function(e){return this.log.debug(e),this.addStatus(e),t("."+this.options.buttonClass).hide(),this.isOpen?(this.disableInput(),this.destroy({remove:!1})):this.destroy({remove:!0})},i.prototype.onReopenSession=function(t){var e,s,n,i,o;if(this.log.debug("old messages",t.session),this.inactiveTimeout.start(),o=sessionStorage.getItem("unfinished_message"),t.agent){for(this.onConnectionEstablished(t),i=t.session,e=0,s=i.length;s>e;e++)n=i[e],this.renderMessage({message:n.content,id:n.id,from:n.created_by_id?"agent":"customer"});o&&this.input.val(o)}return t.position&&this.onQueue(t),this.show(),this.open(),this.scrollToBottom(),o?this.input.focus():void 0},i.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),sessionStorage.setItem("unfinished_message",this.input.val()),this.onTyping()},i.prototype.onFocus=function(){var s;return t(e).scrollTop(10),s=t(e).scrollTop()>0,t(e).scrollTop(0),s?this.log.notice("virtual keyboard shown"):void 0},i.prototype.onFocusOut=function(){},i.prototype.onTyping=function(){return this.isTyping&&this.isTyping>new Date((new Date).getTime()-1500)?void 0:(this.isTyping=new Date,this.send("chat_session_typing",{session_id:this.sessionId}),this.inactiveTimeout.start())},i.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},i.prototype.sendMessage=function(){var t,e;return(t=this.input.val())?(this.inactiveTimeout.start(),sessionStorage.removeItem("unfinished_message"),e=this.view("message")({message:t,from:"customer",id:this._messageCount++,unreadClass:""}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").size()?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.input.val(""),this.scrollToBottom(),this.send("chat_session_message",{content:t,id:this._messageCount,session_id:this.sessionId})):void 0},i.prototype.receiveMessage=function(t){return this.inactiveTimeout.start(),this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.renderMessage({message:t.message.content,id:t.id,from:"agent"}),this.scrollToBottom({showHint:!0})},i.prototype.renderMessage=function(t){return this.lastAddedType="message--"+t.from,t.unreadClass=document.hidden?" zammad-chat-message--unread":"",this.el.find(".zammad-chat-body").append(this.view("message")(t))},i.prototype.open=function(){var t;return this.isOpen?void this.log.debug("widget already open, block"):(this.isOpen=!0,this.log.debug("open widget"),this.sessionId||this.showLoader(),this.el.addClass("zammad-chat-is-open"),this.inputInitialized||(this.inputInitialized=!0,this.input.autoGrow({extraLine:!1})),t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t),this.sessionId?(this.el.css("bottom",0),this.onOpenAnimationEnd()):(this.el.animate({bottom:0},500,this.onOpenAnimationEnd),this.send("chat_session_init",{url:e.location.href})))},i.prototype.onOpenAnimationEnd=function(){return this.idleTimeout.stop(),this.isFullscreen?this.disableScrollOnRoot():void 0},i.prototype.sessionClose=function(){return this.send("chat_session_close",{session_id:this.sessionId}),this.inactiveTimeout.stop(),this.waitingListTimeout.stop(),sessionStorage.removeItem("unfinished_message"),this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.setSessionId(void 0)},i.prototype.toggle=function(t){return this.isOpen?this.close(t):this.open(t)},i.prototype.close=function(t){var e;return this.isOpen?(this.initDelayId&&clearTimeout(this.initDelayId),this.sessionId?(this.log.debug("close widget"),t&&t.stopPropagation(),this.sessionClose(),this.isFullscreen&&this.enableScrollOnRoot(),e=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-e},500,this.onCloseAnimationEnd)):void this.log.debug("can't close widget without sessionId")):void this.log.debug("can't close widget, it's not open")},i.prototype.onCloseAnimationEnd=function(){return this.el.css("bottom",""),this.el.removeClass("zammad-chat-is-open"),this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden"),this.isOpen=!1,this.io.reconnect()},i.prototype.onWebSocketClose=function(){return this.isOpen?void 0:this.el?(this.el.removeClass("zammad-chat-is-shown"),this.el.removeClass("zammad-chat-is-loaded")):void 0},i.prototype.show=function(){return"offline"!==this.state?(this.el.addClass("zammad-chat-is-loaded"),this.el.addClass("zammad-chat-is-shown")):void 0},i.prototype.disableInput=function(){return this.input.prop("disabled",!0),this.el.find(".zammad-chat-send").prop("disabled",!0)},i.prototype.enableInput=function(){return this.input.prop("disabled",!1),this.el.find(".zammad-chat-send").prop("disabled",!1)},i.prototype.hideModal=function(){return this.el.find(".zammad-chat-modal").html("")},i.prototype.onQueueScreen=function(t){var e;return this.setSessionId(t.session_id),e=function(e){return function(){return e.onQueue(t),e.waitingListTimeout.start()}}(this),this.initialQueueDelay&&!this.onInitialQueueDelayId?void(this.onInitialQueueDelayId=setTimeout(e,this.initialQueueDelay)):(this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),e())},i.prototype.onQueue=function(t){return this.log.notice("onQueue",t.position),this.inQueue=!0,this.el.find(".zammad-chat-modal").html(this.view("waiting")({position:t.position}))},i.prototype.onAgentTypingStart=function(){return this.stopTypingId&&clearTimeout(this.stopTypingId),this.stopTypingId=setTimeout(this.onAgentTypingEnd,3e3),!this.el.find(".zammad-chat-message--typing").size()&&(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.isVisible(this.el.find(".zammad-chat-message--typing"),!0))?this.scrollToBottom():void 0},i.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},i.prototype.onLeaveTemporary=function(){return this.sessionId?this.send("chat_session_leave_temporary",{session_id:this.sessionId}):void 0},i.prototype.maybeAddTimestamp=function(){var t,e,s;return s=Date.now(),!this.lastTimestamp||s-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=s):(this.el.find(".zammad-chat-body").append(this.view("timestamp")({label:t,time:e})),this.lastTimestamp=s,this.lastAddedType="timestamp",this.scrollToBottom())):void 0},i.prototype.updateLastTimestamp=function(t,e){return this.el?this.el.find(".zammad-chat-body").find(".zammad-chat-timestamp").last().replaceWith(this.view("timestamp")({label:t,time:e})):void 0},i.prototype.addStatus=function(t){return this.el?(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("status")({status:t})),this.scrollToBottom()):void 0},i.prototype.detectScrolledtoBottom=function(){var t;return t=this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-chat-body").outerHeight(),this.scrolledToBottom=Math.abs(t-this.el.find(".zammad-chat-body").prop("scrollHeight"))<=this.scrollSnapTolerance,this.scrolledToBottom?this.el.find(".zammad-scroll-hint").addClass("is-hidden"):void 0},i.prototype.showScrollHint=function(){return this.el.find(".zammad-scroll-hint").removeClass("is-hidden"),this.el.find(".zammad-chat-body").scrollTop(this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-scroll-hint").outerHeight())},i.prototype.onScrollHintClick=function(){return this.el.find(".zammad-chat-body").animate({scrollTop:this.el.find(".zammad-chat-body").prop("scrollHeight")},300)},i.prototype.scrollToBottom=function(e){var s;return s=(null!=e?e:{showHint:!1}).showHint,this.scrolledToBottom?this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight")):s?this.showScrollHint():void 0},i.prototype.destroy=function(t){return null==t&&(t={}),this.log.debug("destroy widget",t),this.setAgentOnlineState("offline"),t.remove&&this.el&&this.el.remove(),this.waitingListTimeout&&this.waitingListTimeout.stop(),this.inactiveTimeout&&this.inactiveTimeout.stop(),this.idleTimeout&&this.idleTimeout.stop(),this.io.close()},i.prototype.reconnect=function(){return this.log.notice("reconnecting"),this.disableInput(),this.lastAddedType="status",this.setAgentOnlineState("connecting"),this.addStatus(this.T("Connection lost"))},i.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.setAgentOnlineState("online"),this.addStatus(this.T("Connection re-established"))},i.prototype.onSessionClosed=function(t){return this.addStatus(this.T("Chat closed by %s",t.realname)),this.disableInput(),this.setAgentOnlineState("offline"),this.inactiveTimeout.stop()},i.prototype.setSessionId=function(t){return this.sessionId=t,void 0===t?sessionStorage.removeItem("sessionId"):sessionStorage.setItem("sessionId",t)},i.prototype.onConnectionEstablished=function(t){return this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.inQueue=!1,t.agent&&(this.agent=t.agent),t.session_id&&this.setSessionId(t.session_id),this.el.find(".zammad-chat-body").html(""),this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:this.agent})),this.enableInput(),this.hideModal(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.isFullscreen||this.input.focus(),this.setAgentOnlineState("online"),this.waitingListTimeout.stop(),this.idleTimeout.stop(),this.inactiveTimeout.start()},i.prototype.showCustomerTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("customer_timeout")({agent:this.agent.name,delay:this.options.inactiveTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showWaitingListTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("waiting_list_timeout")({delay:this.options.watingListTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showLoader=function(){return this.el.find(".zammad-chat-modal").html(this.view("loader")())},i.prototype.setAgentOnlineState=function(t){var e;return this.state=t,this.el?(e=t.charAt(0).toUpperCase()+t.slice(1),this.el.find(".zammad-chat-agent-status").attr("data-status",t).text(this.T(e))):void 0},i.prototype.detectHost=function(){var t;return t="ws://","https"===l&&(t="wss://"),this.options.host=""+t+h+"/ws"},i.prototype.loadCss=function(){var t,e,s;if(this.options.cssAutoload)return s=this.options.cssUrl,s||(s=this.options.host.replace(/^wss/i,"https").replace(/^ws/i,"http").replace(/\/ws/i,""),s+="/assets/chat/chat.css"),this.log.debug("load css from '"+s+"'"),e="@import url('"+s+"');",t=document.createElement("link"),t.onload=this.onCssLoaded,t.rel="stylesheet",t.href="data:text/css,"+escape(e),document.getElementsByTagName("head")[0].appendChild(t)},i.prototype.onCssLoaded=function(){return this.socketReady?this.onReady():this.cssLoaded=!0},i.prototype.startTimeoutObservers=function(){return this.idleTimeout=new o({logPrefix:"idleTimeout",debug:this.options.debug,timeout:this.options.idleTimeout,timeoutIntervallCheck:this.options.idleTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Idle timeout reached, hide widget",new Date),t.destroy({remove:!0})}}(this)}),this.inactiveTimeout=new o({logPrefix:"inactiveTimeout",debug:this.options.debug,timeout:this.options.inactiveTimeout,timeoutIntervallCheck:this.options.inactiveTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Inactive timeout reached, show timeout screen.",new Date),t.showCustomerTimeout(),t.destroy({remove:!1})}}(this)}),this.waitingListTimeout=new o({logPrefix:"waitingListTimeout",debug:this.options.debug,timeout:this.options.waitingListTimeout,timeoutIntervallCheck:this.options.waitingListTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Waiting list timeout reached, show timeout screen.",new Date),t.showWaitingListTimeout(),t.destroy({remove:!1})}}(this)})},i.prototype.disableScrollOnRoot=function(){return this.rootScrollOffset=this.scrollRoot.scrollTop(),this.scrollRoot.css({overflow:"hidden",position:"fixed"})},i.prototype.enableScrollOnRoot=function(){return this.scrollRoot.scrollTop(this.rootScrollOffset),this.scrollRoot.css({overflow:"",position:""})},i.prototype.isVisible=function(s,n,i,o){var a,r,h,l,d,c,u,p,m,g,f,y,v,b,w,T,z,S,C,I,k,_,O,A,x,E;if(!(s.length<1))if(r=t(e),a=s.length>1?s.eq(0):s,S=a.get(0),E=r.width(),x=r.height(),o=o?o:"both",p=i===!0?S.offsetWidth*S.offsetHeight:!0,"function"==typeof S.getBoundingClientRect){if(z=S.getBoundingClientRect(),C=z.top>=0&&z.top0&&z.bottom<=x,b=z.left>=0&&z.left0&&z.right<=E,I=n?C||u:C&&u,v=n?b||T:b&&T,"both"===o)return p&&I&&v;if("vertical"===o)return p&&I;if("horizontal"===o)return p&&v}else{if(A=r.scrollTop(),k=A+x,_=r.scrollLeft(),O=_+E,w=a.offset(),c=w.top,h=c+a.height(),l=w.left,d=l+a.width(),y=n===!0?h:c,m=n===!0?c:h,g=n===!0?d:l,f=n===!0?l:d,"both"===o)return!!p&&k>=m&&y>=A&&O>=f&&g>=_;if("vertical"===o)return!!p&&k>=m&&y>=A;if("horizontal"===o)return!!p&&O>=f&&g>=_}},i}(s),e.ZammadChat=a}(window.jQuery,window),jQuery.fn.autoGrow=function(t){return this.each(function(){var e=jQuery.extend({extraLine:!0},t),s=function(t){return jQuery(t).after('
'),jQuery(t).next(".autogrow-textarea-mirror")[0]},n=function(t){if(o.innerHTML=String(t.value).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">").replace(/ /g," ").replace(/\n/g,"
")+(e.extraLine?".
.":""),jQuery(t).height()!=jQuery(o).height()){jQuery(t).height(jQuery(o).height());var s=jQuery(o).height()>a?"":"hidden";jQuery(t).css("overflow",s)}},i=function(){n(this)},o=s(this),a=parseInt(jQuery(this).css("max-height"),10);o.style.display="none",o.style.wordWrap="break-word",o.style.whiteSpace="normal",o.style.padding=jQuery(this).css("paddingTop")+" "+jQuery(this).css("paddingRight")+" "+jQuery(this).css("paddingBottom")+" "+jQuery(this).css("paddingLeft"),o.style.width=jQuery(this).css("width"),o.style.fontFamily=jQuery(this).css("font-family"),o.style.fontSize=jQuery(this).css("font-size"),o.style.lineHeight=jQuery(this).css("line-height"),o.style.letterSpacing=jQuery(this).css("letter-spacing"),o.style.boxSizing=jQuery(this).css("boxSizing"), +var bind=function(t,e){return function(){return t.apply(e,arguments)}},slice=[].slice,extend=function(t,e){function s(){this.constructor=t}for(var n in e)hasProp.call(e,n)&&(t[n]=e[n]);return s.prototype=e.prototype,t.prototype=new s,t.__super__=e.prototype,t},hasProp={}.hasOwnProperty;!function(t,e){var s,n,i,o,a,r,h,l,d;return d=document.getElementsByTagName("script"),r=d[d.length-1],h=r.src.match(".*://([^:/]*).*")[1],l=r.src.match("(.*)://[^:/]*.*")[1],s=function(){function e(e){this.options=t.extend({},this.defaults,e),this.log=new i({debug:this.options.debug,logPrefix:this.options.logPrefix||this.logPrefix})}return e.prototype.defaults={debug:!1},e}(),i=function(){function e(e){this.log=bind(this.log,this),this.error=bind(this.error,this),this.notice=bind(this.notice,this),this.debug=bind(this.debug,this),this.options=t.extend({},this.defaults,e)}return e.prototype.defaults={debug:!1},e.prototype.debug=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.options.debug?this.log("debug",t):void 0},e.prototype.notice=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("notice",t)},e.prototype.error=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("error",t)},e.prototype.log=function(e,s){var n,i,o,a;if(s.unshift("||"),s.unshift(e),s.unshift(this.options.logPrefix),console.log.apply(console,s),this.options.debug){for(a="",n=0,o=s.length;o>n;n++)i=s[n],a+=" ",a+="object"==typeof i?JSON.stringify(i):i&&i.toString?i.toString():i;return t(".js-chatLogDisplay").prepend("
"+a+"
")}},e}(),o=function(t){function e(t){this.stop=bind(this.stop,this),this.start=bind(this.start,this),e.__super__.constructor.call(this,t)}return extend(e,t),e.prototype.timeoutStartedAt=null,e.prototype.logPrefix="timeout",e.prototype.defaults={debug:!1,timeout:4,timeoutIntervallCheck:.5},e.prototype.start=function(){var t,e;return this.stop(),e=new Date,t=function(t){return function(){var s;return s=new Date-new Date(e.getTime()+1e3*t.options.timeout*60),t.log.debug("Timeout check for "+t.options.timeout+" minutes (left "+s/1e3+" sec.)"),0>s?void 0:(t.stop(),t.options.callback())}}(this),this.log.debug("Start timeout in "+this.options.timeout+" minutes"),this.intervallId=setInterval(t,1e3*this.options.timeoutIntervallCheck*60)},e.prototype.stop=function(){return this.intervallId?(this.log.debug("Stop timeout of "+this.options.timeout+" minutes"),clearInterval(this.intervallId)):void 0},e}(s),n=function(t){function s(t){this.ping=bind(this.ping,this),this.send=bind(this.send,this),this.reconnect=bind(this.reconnect,this),this.close=bind(this.close,this),this.connect=bind(this.connect,this),this.set=bind(this.set,this),s.__super__.constructor.call(this,t)}return extend(s,t),s.prototype.logPrefix="io",s.prototype.set=function(t){var e,s,n;s=[];for(e in t)n=t[e],s.push(this.options[e]=n);return s},s.prototype.connect=function(){return this.log.debug("Connecting to "+this.options.host),this.ws=new e.WebSocket(""+this.options.host),this.ws.onopen=function(t){return function(e){return t.log.debug("onOpen",e),t.options.onOpen(e),t.ping()}}(this),this.ws.onmessage=function(t){return function(e){var s,n,i,o;for(o=JSON.parse(e.data),t.log.debug("onMessage",e.data),s=0,n=o.length;n>s;s++)i=o[s],"pong"===i.event&&t.ping();return t.options.onMessage?t.options.onMessage(o):void 0}}(this),this.ws.onclose=function(t){return function(e){if(t.log.debug("close websocket connection",e),t.pingDelayId&&clearTimeout(t.pingDelayId),t.manualClose){if(t.log.debug("manual close, onClose callback"),t.manualClose=!1,t.options.onClose)return t.options.onClose(e)}else if(t.log.debug("error close, onError callback"),t.options.onError)return t.options.onError("Connection lost...")}}(this),this.ws.onerror=function(t){return function(e){return t.log.debug("onError",e),t.options.onError?t.options.onError(e):void 0}}(this)},s.prototype.close=function(){return this.log.debug("close websocket manually"),this.manualClose=!0,this.ws.close()},s.prototype.reconnect=function(){return this.log.debug("reconnect"),this.close(),this.connect()},s.prototype.send=function(t,e){var s;return null==e&&(e={}),this.log.debug("send",t,e),s=JSON.stringify({event:t,data:e}),this.ws.send(s)},s.prototype.ping=function(){var t;return t=function(t){return function(){return t.send("ping")}}(this),this.pingDelayId=setTimeout(t,29e3)},s}(s),a=function(s){function i(s){return this.startTimeoutObservers=bind(this.startTimeoutObservers,this),this.onCssLoaded=bind(this.onCssLoaded,this),this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.setSessionId=bind(this.setSessionId,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.destroy=bind(this.destroy,this),this.onScrollHintClick=bind(this.onScrollHintClick,this),this.detectScrolledtoBottom=bind(this.detectScrolledtoBottom,this),this.onLeaveTemporary=bind(this.onLeaveTemporary,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onQueueScreen=bind(this.onQueueScreen,this),this.onWebSocketClose=bind(this.onWebSocketClose,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.close=bind(this.close,this),this.toggle=bind(this.toggle,this),this.sessionClose=bind(this.sessionClose,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.open=bind(this.open,this),this.renderMessage=bind(this.renderMessage,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onFocus=bind(this.onFocus,this),this.onInput=bind(this.onInput,this),this.onReopenSession=bind(this.onReopenSession,this),this.onError=bind(this.onError,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.render=bind(this.render,this),this.view=bind(this.view,this),this.T=bind(this.T,this),this.options=t.extend({},this.defaults,s),i.__super__.constructor.call(this,this.options),this.isFullscreen=e.matchMedia&&e.matchMedia("(max-width: 768px)").matches,this.scrollRoot=t(this.getScrollRoot()),t?e.WebSocket&&sessionStorage?this.options.chatId?(this.options.lang||(this.options.lang=t("html").attr("lang")),this.options.lang&&(this.translations[this.options.lang]||(this.log.debug("lang: No "+this.options.lang+" found, try first two letters"),this.options.lang=this.options.lang.replace(/-.+?$/,"")),this.log.debug("lang: "+this.options.lang)),this.options.host||this.detectHost(),this.loadCss(),this.io=new n(this.options),this.io.set({onOpen:this.render,onClose:this.onWebSocketClose,onMessage:this.onWebSocketMessage,onError:this.onError}),void this.io.connect()):(this.state="unsupported",void this.log.error("Chat: need chatId as option!")):(this.state="unsupported",void this.log.notice("Chat: Browser not supported!")):(this.state="unsupported",void this.log.notice("Chat: no jquery found!"))}return extend(i,s),i.prototype.defaults={chatId:void 0,show:!0,target:t("body"),host:"",debug:!1,flat:!1,lang:void 0,cssAutoload:!0,cssUrl:void 0,fontSize:void 0,buttonClass:"open-zammad-chat",inactiveClass:"is-inactive",title:"Chat with us!",scrollHint:"Scrolle nach unten um neue Nachrichten zu sehen",idleTimeout:6,idleTimeoutIntervallCheck:.5,inactiveTimeout:8,inactiveTimeoutIntervallCheck:.5,waitingListTimeout:4,waitingListTimeoutIntervallCheck:.5},i.prototype.logPrefix="chat",i.prototype._messageCount=0,i.prototype.isOpen=!1,i.prototype.blinkOnlineInterval=null,i.prototype.stopBlinOnlineStateTimeout=null,i.prototype.showTimeEveryXMinutes=2,i.prototype.lastTimestamp=null,i.prototype.lastAddedType=null,i.prototype.inputTimeout=null,i.prototype.isTyping=!1,i.prototype.state="offline",i.prototype.initialQueueDelay=1e4,i.prototype.translations={de:{"Chat with us!":"Chatte mit uns!","Scroll down to see new messages":"Scrolle nach unten um neue Nachrichten zu sehen",Online:"Online",Online:"Online",Offline:"Offline",Connecting:"Verbinden","Connection re-established":"Verbindung wiederhergestellt",Today:"Heute",Send:"Senden","Compose your message...":"Ihre Nachricht...","All colleagues are busy.":"Alle Kollegen sind belegt.","You are on waiting list position %s.":"Sie sind in der Warteliste an der Position %s.","Start new conversation":"Neue Konversation starten","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit %s geschlossen.","Since you didn't respond in the last %s minutes your conversation got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Es tut uns leid, es dauert länger als erwartet, um einen freien Platz zu erhalten. Bitte versuchen Sie es zu einem späteren Zeitpunkt noch einmal oder schicken Sie uns eine E-Mail. Vielen Dank!"},fr:{"Chat with us!":"Chattez avec nous!","Scroll down to see new messages":"Faites défiler pour lire les nouveaux messages",Online:"En-ligne",Online:"En-ligne",Offline:"Hors-ligne",Connecting:"Connexion en cours","Connection re-established":"Connexion rétablie",Today:"Aujourdhui",Send:"Envoyer","Compose your message...":"Composez votre message...","All colleagues are busy.":"Tous les collègues sont actuellement occupés.","You are on waiting list position %s.":"Vous êtes actuellement en %s position dans la file d'attente.","Start new conversation":"Démarrer une nouvelle conversation","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation avec %s va être fermée.","Since you didn't respond in the last %s minutes your conversation got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation va être fermée.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Nous sommes désolés, il faut plus de temps que prévu pour obtenir un emplacement vide. Veuillez réessayer ultérieurement ou nous envoyer un courriel. Je vous remercie!"},"zh-cn":{"Chat with us!":"发起即时对话!","Scroll down to see new messages":"向下滚动以查看新消息",Online:"在线",Online:"在线",Offline:"离线",Connecting:"连接中","Connection re-established":"正在重新建立连接",Today:"今天",Send:"发送","Compose your message...":"正在输入信息...","All colleagues are busy.":"所有工作人员都在忙碌中.","You are on waiting list position %s.":"您目前的等候位置是第 %s 位.","Start new conversation":"开始新的会话","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由于您超过 %s 分钟没有回复, 您与 %s 的会话已被关闭.","Since you didn't respond in the last %s minutes your conversation got closed.":"由于您超过 %s 分钟没有任何回复, 该对话已被关闭.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 目前需要等候更长的时间才能接入对话, 请稍后重试或向我们发送电子邮件. 谢谢!"},"zh-tw":{"Chat with us!":"開始即時對话!","Scroll down to see new messages":"向下滑動以查看新訊息",Online:"線上",Online:"線上",Offline:"离线",Connecting:"連線中","Connection re-established":"正在重新建立連線中",Today:"今天",Send:"發送","Compose your message...":"正在輸入訊息...","All colleagues are busy.":"所有服務人員都在忙碌中.","You are on waiting list position %s.":"你目前的等候位置是第 %s 順位.","Start new conversation":"開始新的對話","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由於你超過 %s 分鐘沒有回應, 你與 %s 的對話已被關閉.","Since you didn't respond in the last %s minutes your conversation got closed.":"由於你超過 %s 分鐘沒有任何回應, 該對話已被關閉.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 當前需要等候更長的時間方可排入對話程序, 請稍後重試或向我們寄送電子郵件. 謝謝!"}},i.prototype.sessionId=void 0,i.prototype.scrolledToBottom=!0,i.prototype.scrollSnapTolerance=10,i.prototype.T=function(){var t,e,s,n,i,o;if(i=arguments[0],s=2<=arguments.length?slice.call(arguments,1):[],this.options.lang&&"en"!==this.options.lang&&(this.translations[this.options.lang]?(o=this.translations[this.options.lang],o[i]||this.log.notice("Translation needed for '"+i+"'"),i=o[i]||i):this.log.notice("Translation '"+this.options.lang+"' needed!")),s)for(t=0,n=s.length;n>t;t++)e=s[t],i=i.replace(/%s/,e);return i},i.prototype.view=function(t){return function(s){return function(n){return n||(n={}),n.T=s.T,n.background=s.options.background,n.flat=s.options.flat,n.fontSize=s.options.fontSize,e.zammadChatTemplates[t](n)}}(this)},i.prototype.getScrollRoot=function(){var t,e,s;return"scrollingElement"in document?document.scrollingElement:(e=document.documentElement,s=e.scrollTop,e.scrollTop=s+1,t=e.scrollTop,e.scrollTop=s,t>s?e:document.body)},i.prototype.render=function(){return this.el&&t(".zammad-chat").get(0)||this.renderBase(),t("."+this.options.buttonClass).addClass(this.inactiveClass),this.setAgentOnlineState("online"),this.log.debug("widget rendered"),this.startTimeoutObservers(),this.idleTimeout.start(),this.sessionId=sessionStorage.getItem("sessionId"),this.send("chat_status_customer",{session_id:this.sessionId,url:e.location.href})},i.prototype.renderBase=function(){return this.el=t(this.view("chat")({title:this.options.title,scrollHint:this.options.scrollHint})),this.options.target.append(this.el),this.input=this.el.find(".zammad-chat-input"),this.el.find(".js-chat-open").click(this.open),this.el.find(".js-chat-toggle").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-body").on("scroll",this.detectScrolledtoBottom),this.el.find(".zammad-scroll-hint").click(this.onScrollHintClick),this.input.on({keydown:this.checkForEnter,input:this.onInput}),t(e).on("beforeunload",function(t){return function(){return t.onLeaveTemporary()}}(this)),t(e).bind("hashchange",function(t){return function(){return t.isOpen?void(t.sessionId&&t.send("chat_session_notice",{session_id:t.sessionId,message:e.location.href})):t.idleTimeout.start()}}(this)),this.isFullscreen?this.input.on({focus:this.onFocus,focusout:this.onFocusOut}):void 0},i.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},i.prototype.send=function(t,e){return null==e&&(e={}),e.chat_id=this.options.chatId,this.io.send(t,e)},i.prototype.onWebSocketMessage=function(t){var e,s,n;for(e=0,s=t.length;s>e;e++)switch(n=t[e],this.log.debug("ws:onmessage",n),n.event){case"chat_error":this.log.notice(n.data),n.data&&"chat_disabled"===n.data.state&&this.destroy({remove:!0});break;case"chat_session_message":if(n.data.self_written)return;this.receiveMessage(n.data);break;case"chat_session_typing":if(n.data.self_written)return;this.onAgentTypingStart();break;case"chat_session_start":this.onConnectionEstablished(n.data);break;case"chat_session_queue":this.onQueueScreen(n.data);break;case"chat_session_closed":this.onSessionClosed(n.data);break;case"chat_session_left":this.onSessionClosed(n.data);break;case"chat_status_customer":switch(n.data.state){case"online":this.sessionId=void 0,!this.options.cssAutoload||this.cssLoaded?this.onReady():this.socketReady=!0;break;case"offline":this.onError("Zammad Chat: No agent online");break;case"chat_disabled":this.onError("Zammad Chat: Chat is disabled");break;case"no_seats_available":this.onError("Zammad Chat: Too many clients in queue. Clients in queue: "+n.data.queue);break;case"reconnect":this.onReopenSession(n.data)}}},i.prototype.onReady=function(){return this.log.debug("widget ready for use"),t("."+this.options.buttonClass).click(this.open).removeClass(this.inactiveClass),this.options.show?this.show():void 0},i.prototype.onError=function(e){return this.log.debug(e),this.addStatus(e),t("."+this.options.buttonClass).hide(),this.isOpen?(this.disableInput(),this.destroy({remove:!1})):this.destroy({remove:!0})},i.prototype.onReopenSession=function(t){var e,s,n,i,o;if(this.log.debug("old messages",t.session),this.inactiveTimeout.start(),o=sessionStorage.getItem("unfinished_message"),t.agent){for(this.onConnectionEstablished(t),i=t.session,e=0,s=i.length;s>e;e++)n=i[e],this.renderMessage({message:n.content,id:n.id,from:n.created_by_id?"agent":"customer"});o&&this.input.val(o)}return t.position&&this.onQueue(t),this.show(),this.open(),this.scrollToBottom(),o?this.input.focus():void 0},i.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),sessionStorage.setItem("unfinished_message",this.input.val()),this.onTyping()},i.prototype.onFocus=function(){var s;return t(e).scrollTop(10),s=t(e).scrollTop()>0,t(e).scrollTop(0),s?this.log.notice("virtual keyboard shown"):void 0},i.prototype.onFocusOut=function(){},i.prototype.onTyping=function(){return this.isTyping&&this.isTyping>new Date((new Date).getTime()-1500)?void 0:(this.isTyping=new Date,this.send("chat_session_typing",{session_id:this.sessionId}),this.inactiveTimeout.start())},i.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},i.prototype.sendMessage=function(){var t,e;return(t=this.input.val())?(this.inactiveTimeout.start(),sessionStorage.removeItem("unfinished_message"),e=this.view("message")({message:t,from:"customer",id:this._messageCount++,unreadClass:""}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").get(0)?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.input.val(""),this.scrollToBottom(),this.send("chat_session_message",{content:t,id:this._messageCount,session_id:this.sessionId})):void 0},i.prototype.receiveMessage=function(t){return this.inactiveTimeout.start(),this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.renderMessage({message:t.message.content,id:t.id,from:"agent"}),this.scrollToBottom({showHint:!0})},i.prototype.renderMessage=function(t){return this.lastAddedType="message--"+t.from,t.unreadClass=document.hidden?" zammad-chat-message--unread":"",this.el.find(".zammad-chat-body").append(this.view("message")(t))},i.prototype.open=function(){var t;return this.isOpen?void this.log.debug("widget already open, block"):(this.isOpen=!0,this.log.debug("open widget"),this.sessionId||this.showLoader(),this.el.addClass("zammad-chat-is-open"),this.inputInitialized||(this.inputInitialized=!0,this.input.autoGrow({extraLine:!1})),t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t),this.sessionId?(this.el.css("bottom",0),this.onOpenAnimationEnd()):(this.el.animate({bottom:0},500,this.onOpenAnimationEnd),this.send("chat_session_init",{url:e.location.href})))},i.prototype.onOpenAnimationEnd=function(){return this.idleTimeout.stop(),this.isFullscreen?this.disableScrollOnRoot():void 0},i.prototype.sessionClose=function(){return this.send("chat_session_close",{session_id:this.sessionId}),this.inactiveTimeout.stop(),this.waitingListTimeout.stop(),sessionStorage.removeItem("unfinished_message"),this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.setSessionId(void 0)},i.prototype.toggle=function(t){return this.isOpen?this.close(t):this.open(t)},i.prototype.close=function(t){var e;return this.isOpen?(this.initDelayId&&clearTimeout(this.initDelayId),this.sessionId?(this.log.debug("close widget"),t&&t.stopPropagation(),this.sessionClose(),this.isFullscreen&&this.enableScrollOnRoot(),e=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-e},500,this.onCloseAnimationEnd)):void this.log.debug("can't close widget without sessionId")):void this.log.debug("can't close widget, it's not open")},i.prototype.onCloseAnimationEnd=function(){return this.el.css("bottom",""),this.el.removeClass("zammad-chat-is-open"),this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden"),this.isOpen=!1,this.io.reconnect()},i.prototype.onWebSocketClose=function(){return this.isOpen?void 0:this.el?(this.el.removeClass("zammad-chat-is-shown"),this.el.removeClass("zammad-chat-is-loaded")):void 0},i.prototype.show=function(){return"offline"!==this.state?(this.el.addClass("zammad-chat-is-loaded"),this.el.addClass("zammad-chat-is-shown")):void 0},i.prototype.disableInput=function(){return this.input.prop("disabled",!0),this.el.find(".zammad-chat-send").prop("disabled",!0)},i.prototype.enableInput=function(){return this.input.prop("disabled",!1),this.el.find(".zammad-chat-send").prop("disabled",!1)},i.prototype.hideModal=function(){return this.el.find(".zammad-chat-modal").html("")},i.prototype.onQueueScreen=function(t){var e;return this.setSessionId(t.session_id),e=function(e){return function(){return e.onQueue(t),e.waitingListTimeout.start()}}(this),this.initialQueueDelay&&!this.onInitialQueueDelayId?void(this.onInitialQueueDelayId=setTimeout(e,this.initialQueueDelay)):(this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),e())},i.prototype.onQueue=function(t){return this.log.notice("onQueue",t.position),this.inQueue=!0,this.el.find(".zammad-chat-modal").html(this.view("waiting")({position:t.position}))},i.prototype.onAgentTypingStart=function(){return this.stopTypingId&&clearTimeout(this.stopTypingId),this.stopTypingId=setTimeout(this.onAgentTypingEnd,3e3),!this.el.find(".zammad-chat-message--typing").get(0)&&(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.isVisible(this.el.find(".zammad-chat-message--typing"),!0))?this.scrollToBottom():void 0},i.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},i.prototype.onLeaveTemporary=function(){return this.sessionId?this.send("chat_session_leave_temporary",{session_id:this.sessionId}):void 0},i.prototype.maybeAddTimestamp=function(){var t,e,s;return s=Date.now(),!this.lastTimestamp||s-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=s):(this.el.find(".zammad-chat-body").append(this.view("timestamp")({label:t,time:e})),this.lastTimestamp=s,this.lastAddedType="timestamp",this.scrollToBottom())):void 0},i.prototype.updateLastTimestamp=function(t,e){return this.el?this.el.find(".zammad-chat-body").find(".zammad-chat-timestamp").last().replaceWith(this.view("timestamp")({label:t,time:e})):void 0},i.prototype.addStatus=function(t){return this.el?(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("status")({status:t})),this.scrollToBottom()):void 0},i.prototype.detectScrolledtoBottom=function(){var t;return t=this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-chat-body").outerHeight(),this.scrolledToBottom=Math.abs(t-this.el.find(".zammad-chat-body").prop("scrollHeight"))<=this.scrollSnapTolerance,this.scrolledToBottom?this.el.find(".zammad-scroll-hint").addClass("is-hidden"):void 0},i.prototype.showScrollHint=function(){return this.el.find(".zammad-scroll-hint").removeClass("is-hidden"),this.el.find(".zammad-chat-body").scrollTop(this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-scroll-hint").outerHeight())},i.prototype.onScrollHintClick=function(){return this.el.find(".zammad-chat-body").animate({scrollTop:this.el.find(".zammad-chat-body").prop("scrollHeight")},300)},i.prototype.scrollToBottom=function(e){var s;return s=(null!=e?e:{showHint:!1}).showHint,this.scrolledToBottom?this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight")):s?this.showScrollHint():void 0},i.prototype.destroy=function(t){return null==t&&(t={}),this.log.debug("destroy widget",t),this.setAgentOnlineState("offline"),t.remove&&this.el&&this.el.remove(),this.waitingListTimeout&&this.waitingListTimeout.stop(),this.inactiveTimeout&&this.inactiveTimeout.stop(),this.idleTimeout&&this.idleTimeout.stop(),this.io.close()},i.prototype.reconnect=function(){return this.log.notice("reconnecting"),this.disableInput(),this.lastAddedType="status",this.setAgentOnlineState("connecting"),this.addStatus(this.T("Connection lost"))},i.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.setAgentOnlineState("online"),this.addStatus(this.T("Connection re-established"))},i.prototype.onSessionClosed=function(t){return this.addStatus(this.T("Chat closed by %s",t.realname)),this.disableInput(),this.setAgentOnlineState("offline"),this.inactiveTimeout.stop()},i.prototype.setSessionId=function(t){return this.sessionId=t,void 0===t?sessionStorage.removeItem("sessionId"):sessionStorage.setItem("sessionId",t)},i.prototype.onConnectionEstablished=function(t){return this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.inQueue=!1,t.agent&&(this.agent=t.agent),t.session_id&&this.setSessionId(t.session_id),this.el.find(".zammad-chat-body").html(""),this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:this.agent})),this.enableInput(),this.hideModal(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.isFullscreen||this.input.focus(),this.setAgentOnlineState("online"),this.waitingListTimeout.stop(),this.idleTimeout.stop(),this.inactiveTimeout.start()},i.prototype.showCustomerTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("customer_timeout")({agent:this.agent.name,delay:this.options.inactiveTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showWaitingListTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("waiting_list_timeout")({delay:this.options.watingListTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showLoader=function(){return this.el.find(".zammad-chat-modal").html(this.view("loader")())},i.prototype.setAgentOnlineState=function(t){var e;return this.state=t,this.el?(e=t.charAt(0).toUpperCase()+t.slice(1),this.el.find(".zammad-chat-agent-status").attr("data-status",t).text(this.T(e))):void 0},i.prototype.detectHost=function(){var t;return t="ws://","https"===l&&(t="wss://"),this.options.host=""+t+h+"/ws"},i.prototype.loadCss=function(){var t,e,s;if(this.options.cssAutoload)return s=this.options.cssUrl,s||(s=this.options.host.replace(/^wss/i,"https").replace(/^ws/i,"http").replace(/\/ws/i,""),s+="/assets/chat/chat.css"),this.log.debug("load css from '"+s+"'"),e="@import url('"+s+"');",t=document.createElement("link"),t.onload=this.onCssLoaded,t.rel="stylesheet",t.href="data:text/css,"+escape(e),document.getElementsByTagName("head")[0].appendChild(t)},i.prototype.onCssLoaded=function(){return this.socketReady?this.onReady():this.cssLoaded=!0},i.prototype.startTimeoutObservers=function(){return this.idleTimeout=new o({logPrefix:"idleTimeout",debug:this.options.debug,timeout:this.options.idleTimeout,timeoutIntervallCheck:this.options.idleTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Idle timeout reached, hide widget",new Date),t.destroy({remove:!0})}}(this)}),this.inactiveTimeout=new o({logPrefix:"inactiveTimeout",debug:this.options.debug,timeout:this.options.inactiveTimeout,timeoutIntervallCheck:this.options.inactiveTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Inactive timeout reached, show timeout screen.",new Date),t.showCustomerTimeout(),t.destroy({remove:!1})}}(this)}),this.waitingListTimeout=new o({logPrefix:"waitingListTimeout",debug:this.options.debug,timeout:this.options.waitingListTimeout,timeoutIntervallCheck:this.options.waitingListTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Waiting list timeout reached, show timeout screen.",new Date),t.showWaitingListTimeout(),t.destroy({remove:!1})}}(this)})},i.prototype.disableScrollOnRoot=function(){return this.rootScrollOffset=this.scrollRoot.scrollTop(),this.scrollRoot.css({overflow:"hidden",position:"fixed"})},i.prototype.enableScrollOnRoot=function(){return this.scrollRoot.scrollTop(this.rootScrollOffset),this.scrollRoot.css({overflow:"",position:""})},i.prototype.isVisible=function(s,n,i,o){var a,r,h,l,d,c,u,p,m,g,f,y,v,b,w,T,z,S,C,I,k,_,O,A,x,E;if(!(s.length<1))if(r=t(e),a=s.length>1?s.eq(0):s,S=a.get(0),E=r.width(),x=r.height(),o=o?o:"both",p=i===!0?S.offsetWidth*S.offsetHeight:!0,"function"==typeof S.getBoundingClientRect){if(z=S.getBoundingClientRect(),C=z.top>=0&&z.top0&&z.bottom<=x,b=z.left>=0&&z.left0&&z.right<=E,I=n?C||u:C&&u,v=n?b||T:b&&T,"both"===o)return p&&I&&v;if("vertical"===o)return p&&I;if("horizontal"===o)return p&&v}else{if(A=r.scrollTop(),k=A+x,_=r.scrollLeft(),O=_+E,w=a.offset(),c=w.top,h=c+a.height(),l=w.left,d=l+a.width(),y=n===!0?h:c,m=n===!0?c:h,g=n===!0?d:l,f=n===!0?l:d,"both"===o)return!!p&&k>=m&&y>=A&&O>=f&&g>=_;if("vertical"===o)return!!p&&k>=m&&y>=A;if("horizontal"===o)return!!p&&O>=f&&g>=_}},i}(s),e.ZammadChat=a}(window.jQuery,window),window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){this.agent.avatar&&(s.push('\n\n')),s.push('\n\n '),s.push(n(this.agent.name)),s.push("\n")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},jQuery.fn.autoGrow=function(t){return this.each(function(){var e=jQuery.extend({extraLine:!0},t),s=function(t){return jQuery(t).after('
'),jQuery(t).next(".autogrow-textarea-mirror")[0]},n=function(t){if(o.innerHTML=String(t.value).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">").replace(/ /g," ").replace(/\n/g,"
")+(e.extraLine?".
.":""),jQuery(t).height()!=jQuery(o).height()){jQuery(t).height(jQuery(o).height());var s=jQuery(o).height()>a?"":"hidden";jQuery(t).css("overflow",s)}},i=function(){n(this)},o=s(this),a=parseInt(jQuery(this).css("max-height"),10);o.style.display="none",o.style.wordWrap="break-word",o.style.whiteSpace="normal",o.style.padding=jQuery(this).css("paddingTop")+" "+jQuery(this).css("paddingRight")+" "+jQuery(this).css("paddingBottom")+" "+jQuery(this).css("paddingLeft"),o.style.width=jQuery(this).css("width"),o.style.fontFamily=jQuery(this).css("font-family"),o.style.fontSize=jQuery(this).css("font-size"),o.style.lineHeight=jQuery(this).css("line-height"),o.style.letterSpacing=jQuery(this).css("letter-spacing"),o.style.boxSizing=jQuery(this).css("boxSizing"), this.style.overflow="hidden",this.style.minHeight=this.rows+"em",this.onkeyup=i,this.onfocus=i,n(this)})},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.chat=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n
\n
\n \n \n \n \n \n
\n
\n
\n
\n \n '),s.push(this.T(this.title)),s.push('\n
\n
\n
\n \n
\n \n \n \n \n
")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.customer_timeout=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n '),this.agent?(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation with %s got closed.",this.delay,this.agent)),s.push("\n ")):(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation got closed.",this.delay)),s.push("\n ")),s.push('\n
\n
"),s.push(this.T("Start new conversation")),s.push("
\n
")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.loader=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('\n \n \n \n\n'),s.push(this.T("Connecting")),s.push("")}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.message=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n "),s.push(this.message),s.push("\n
")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.status=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n
\n '),s.push(this.status),s.push("\n
\n
")}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.timestamp=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
'),s.push(n(this.label)),s.push(" "),s.push(n(this.time)),s.push("
")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.typingIndicator=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n \n \n \n \n \n \n \n
')}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n \n \n \n \n \n '),s.push(this.T("All colleagues are busy.")),s.push("
\n "),s.push(this.T("You are on waiting list position %s.",this.position)),s.push("\n
")}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting_list_timeout=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
\n '),s.push(this.T("We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!")),s.push('\n
\n
"),s.push(this.T("Start new conversation")),s.push("
\n
")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")}; \ No newline at end of file diff --git a/public/assets/form/form.html b/public/assets/form/form.html index 3e8577459..9053ae955 100644 --- a/public/assets/form/form.html +++ b/public/assets/form/form.html @@ -14,7 +14,7 @@
-
+

Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.

diff --git a/public/assets/form/form.js b/public/assets/form/form.js index ed4d2d7ed..fa21bed91 100644 --- a/public/assets/form/form.js +++ b/public/assets/form/form.js @@ -204,8 +204,14 @@ $(function() { if (this.options.test) { params.test = true } + + params.fingerprint = this.fingerprint() + $.ajax({ + method: 'post', url: _this.endpoint_config, + cache: false, + processData: true, data: params }).done(function(data) { _this.log('debug', 'config:', data) @@ -256,7 +262,7 @@ $(function() { _this.log('debug', 'currentTime', currentTime) _this.log('debug', 'modalOpenTime', _this.modalOpenTime.getTime()) _this.log('debug', 'diffTime', diff) - if (diff < 1000*8) { + if (diff < 1000*10) { alert('Sorry, you look like an robot!') return } @@ -317,7 +323,10 @@ $(function() { formData.append('test', true) } formData.append('token', this._config.token) + + formData.append('fingerprint', this.fingerprint()) _this.log('debug', 'formData', formData) + return formData } @@ -463,6 +472,22 @@ $(function() { return string } + Plugin.prototype.fingerprint = function () { + var canvas = document.createElement('canvas') + var ctx = canvas.getContext('2d') + var txt = 'https://zammad.com' + ctx.textBaseline = 'top' + ctx.font = '12px \'Arial\'' + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = '#f60' + ctx.fillRect(125,1,62,20) + ctx.fillStyle = '#069' + ctx.fillText(txt, 2, 15) + ctx.fillStyle = 'rgba(100, 200, 0, 0.7)' + ctx.fillText(txt, 4, 17) + return canvas.toDataURL() + } + $.fn[pluginName] = function (options) { return this.each(function () { var instance = $.data(this, 'plugin_' + pluginName) diff --git a/script/bootstrap.sh b/script/bootstrap.sh index ac34cdc3d..f8bcb41ae 100755 --- a/script/bootstrap.sh +++ b/script/bootstrap.sh @@ -1,6 +1,6 @@ #!/bin/bash -bundle install +bundle install --jobs 8 rm -rf tmp/cache* diff --git a/script/install.sh b/script/install.sh index 67da70b27..b5a44c868 100644 --- a/script/install.sh +++ b/script/install.sh @@ -79,7 +79,7 @@ sudo -u "${USER}" -H bash -l -c 'rvm alias create default 2.1.2' sudo -u "${USER}" -H bash -l -c 'cd ~/zammad && gem install rails --no-ri --no-rdoc' -sudo -u "${USER}" -H bash -l -c 'cd ~/zammad && bundle install' +sudo -u "${USER}" -H bash -l -c 'cd ~/zammad && bundle install --jobs 8' DBPASS=$(apg -x8|head -1) echo Password $DBPASS diff --git a/script/local_browser_tests.sh b/script/local_browser_tests.sh index 13a6bede2..6c3ada269 100755 --- a/script/local_browser_tests.sh +++ b/script/local_browser_tests.sh @@ -15,7 +15,7 @@ export RAILS_SERVE_STATIC_FILES=true export ZAMMAD_SETTING_TTL=15 export Z_LOCALES=en-us:de-de -bundle install +bundle install --jobs 8 rm -rf tmp/screenshot* rm -rf tmp/cache* diff --git a/spec/lib/import/ldap/user_factory_spec.rb b/spec/lib/import/ldap/user_factory_spec.rb index 05c34b0c0..d0ea4b2f3 100644 --- a/spec/lib/import/ldap/user_factory_spec.rb +++ b/spec/lib/import/ldap/user_factory_spec.rb @@ -49,22 +49,25 @@ RSpec.describe Import::Ldap::UserFactory do }.by(1) end - it 'supports dry run' do + it 'deactivates lost users' do config = { user_filter: '(objectClass=user)', group_filter: '(objectClass=group)', user_uid: 'uid', user_attributes: { - 'uid' => 'login', + 'uid' => 'login', 'email' => 'email', } } - mocked_entry = build(:ldap_entry) + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] - mocked_entry['uid'] = ['exampleuid'] - mocked_entry['email'] = ['example@example.com'] + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid_lost'] + lost_entry['email'] = ['lost@example.com'] mocked_ldap = double( host: 'ldap.example.com', @@ -73,22 +76,266 @@ RSpec.describe Import::Ldap::UserFactory do base_dn: 'dc=example,dc=com' ) + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + # group user role mapping expect(mocked_ldap).to receive(:search) # user counting expect(mocked_ldap).to receive(:count).and_return(1) # user search - expect(mocked_ldap).to receive(:search).and_yield(mocked_entry) + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) expect do + described_class.import( + config: config, + ldap: mocked_ldap, + ) + end.to change { + User.find_by(email: 'lost@example.com').active + } + end + + it 're-activates previously lost users' do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid_lost'] + lost_entry['email'] = ['lost@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + ) + end.to change { + User.find_by(email: 'lost@example.com').active + } + end + + it 'deactivates skipped users' do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + }, + } + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid'] + lost_entry['email'] = ['example@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # activate skipping + config[:unassigned_users] = 'skip_sync' + config[:group_role_map] = { + 'dummy' => %w(1 2), + } + + # group user role mapping + mocked_entry = build(:ldap_entry) + mocked_entry['dn'] = 'dummy' + mocked_entry['member'] = ['dummy'] + expect(mocked_ldap).to receive(:search).and_yield(mocked_entry) + + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(lost_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + ) + end.to change { + User.find_by(email: 'example@example.com').active + } + end + + context 'dry run' do + + it "doesn't sync users" do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + mocked_entry = build(:ldap_entry) + + mocked_entry['uid'] = ['exampleuid'] + mocked_entry['email'] = ['example@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(mocked_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + dry_run: true + ) + end.not_to change { + User.count + } + end + + it "doesn't deactivates lost users" do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid'] + lost_entry['email'] = ['example@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + described_class.import( config: config, ldap: mocked_ldap, dry_run: true ) - end.not_to change { - User.count - } + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + dry_run: true + ) + end.not_to change { + User.count + } + end end end @@ -115,23 +362,91 @@ RSpec.describe Import::Ldap::UserFactory do expected = { role_ids: { 1 => { - created: 1, - updated: 0, - unchanged: 0, - failed: 0 + created: 1, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, }, 2 => { - created: 1, - updated: 0, - unchanged: 0, - failed: 0 + created: 1, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, }, }, - skipped: 0, - created: 1, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 0, + created: 1, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, + } + + expect(described_class.statistics).to include(expected) + end + + it 'adds deactivated users' do + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid_lost'] + lost_entry['email'] = ['lost@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + allow(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # simulate new import + described_class.reset_statistics + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + allow(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + expected = { + skipped: 0, + created: 0, + updated: 0, + unchanged: 1, + failed: 0, + deactivated: 1, } expect(described_class.statistics).to include(expected) @@ -150,11 +465,12 @@ RSpec.describe Import::Ldap::UserFactory do described_class.add_to_statistics(mocked_backend_instance) expected = { - skipped: 1, - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 1, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } expect(described_class.statistics).to include(expected) @@ -175,11 +491,12 @@ RSpec.describe Import::Ldap::UserFactory do described_class.add_to_statistics(mocked_backend_instance) expected = { - skipped: 1, - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 1, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } expect(described_class.statistics).to include(expected) diff --git a/spec/lib/import/statistical_factory_spec.rb b/spec/lib/import/statistical_factory_spec.rb index 02bc905a9..728c28527 100644 --- a/spec/lib/import/statistical_factory_spec.rb +++ b/spec/lib/import/statistical_factory_spec.rb @@ -45,11 +45,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 1, - updated: 0, - unchanged: 0, - skipped: 0, - failed: 0, + created: 1, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -67,11 +68,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -90,11 +92,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -113,11 +116,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -134,11 +138,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 0, - unchanged: 1, - skipped: 0, - failed: 0, + created: 0, + updated: 0, + unchanged: 1, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -151,11 +156,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes], dry_run: true) statistics = { - created: 1, - updated: 0, - unchanged: 0, - skipped: 0, - failed: 0, + created: 1, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -181,11 +187,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([update_attributes], dry_run: true) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -199,11 +206,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([update_attributes], dry_run: true) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -217,11 +225,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([update_attributes], dry_run: true) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -241,11 +250,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([local_group.attributes], dry_run: true) statistics = { - created: 0, - updated: 0, - unchanged: 1, - skipped: 0, - failed: 0, + created: 0, + updated: 0, + unchanged: 1, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end diff --git a/spec/support/cache.rb b/spec/support/cache.rb index 4a6b30119..ff2569724 100644 --- a/spec/support/cache.rb +++ b/spec/support/cache.rb @@ -1,5 +1,5 @@ RSpec.configure do |config| - config.before(:all) do + config.before(:each) do # clear the cache otherwise it won't # be able to recognize the rollbacks # done by RSpec diff --git a/test/browser/form_test.rb b/test/browser/form_test.rb index 5bb89f5d0..8b6f35a91 100644 --- a/test/browser/form_test.rb +++ b/test/browser/form_test.rb @@ -82,23 +82,6 @@ class FormTest < TestCase browser: agent, css: 'body div.zammad-form-modal button[type="submit"][disabled]', ) - set( - browser: agent, - css: 'body div.zammad-form-modal [name="email"]', - value: 'notexistinginanydomainspacealsonothere@znuny.com', - ) - click( - browser: agent, - css: 'body div.zammad-form-modal button[type="submit"]', - ) - watch_for( - browser: agent, - css: 'body div.zammad-form-modal .has-error [name="email"]', - ) - watch_for_disappear( - browser: agent, - css: 'body div.zammad-form-modal button[type="submit"][disabled]', - ) set( browser: agent, css: 'body div.zammad-form-modal [name="email"]', @@ -315,23 +298,6 @@ class FormTest < TestCase browser: customer, css: 'body div.zammad-form-modal button[type="submit"][disabled]', ) - set( - browser: customer, - css: 'body div.zammad-form-modal [name="email"]', - value: 'notexistinginanydomainspacealsonothere@znuny.com', - ) - click( - browser: customer, - css: 'body div.zammad-form-modal button[type="submit"]', - ) - watch_for( - browser: customer, - css: 'body div.zammad-form-modal .has-error [name="email"]', - ) - watch_for_disappear( - browser: customer, - css: 'body div.zammad-form-modal button[type="submit"][disabled]', - ) set( browser: customer, css: 'body div.zammad-form-modal [name="email"]', diff --git a/test/browser_test_helper.rb b/test/browser_test_helper.rb index 1e221f193..f0c42a8d1 100644 --- a/test/browser_test_helper.rb +++ b/test/browser_test_helper.rb @@ -2261,7 +2261,7 @@ wait untill text in selector disabppears 9.times { begin text = instance.find_elements(css: '.content.active .js-reset')[0].text - if !text || text.empty? + if text.blank? screenshot(browser: instance, comment: 'ticket_update_ok') sleep 1 return true diff --git a/test/controllers/form_controller_test.rb b/test/controllers/form_controller_test.rb new file mode 100644 index 000000000..f3c8e3612 --- /dev/null +++ b/test/controllers/form_controller_test.rb @@ -0,0 +1,241 @@ +# encoding: utf-8 +require 'test_helper' +require 'rake' + +class FormControllerTest < ActionDispatch::IntegrationTest + setup do + @headers = { 'ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json', 'REMOTE_ADDR' => '1.2.3.4' } + + if ENV['ES_URL'].present? + + #fail "ERROR: Need ES_URL - hint ES_URL='http://127.0.0.1:9200'" + Setting.set('es_url', ENV['ES_URL']) + + # Setting.set('es_url', 'http://127.0.0.1:9200') + # Setting.set('es_index', 'estest.local_zammad') + # Setting.set('es_user', 'elasticsearch') + # Setting.set('es_password', 'zammad') + + if ENV['ES_INDEX_RAND'].present? + ENV['ES_INDEX'] = "es_index_#{rand(999_999_999)}" + end + if ENV['ES_INDEX'].blank? + raise "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" + end + Setting.set('es_index', ENV['ES_INDEX']) + end + + Ticket.destroy_all + + # drop/create indexes + Setting.reload + Rake::Task.clear + Zammad::Application.load_tasks + Rake::Task['searchindex:rebuild'].execute + end + + test '01 - get config call' do + post '/api/v1/form_config', {}.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + end + + test '02 - get config call' do + Setting.set('form_ticket_create', true) + post '/api/v1/form_config', {}.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + + end + + test '03 - get config call & do submit' do + Setting.set('form_ticket_create', true) + fingerprint = SecureRandom.hex(40) + post '/api/v1/form_config', { fingerprint: fingerprint }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['enabled'], true) + assert_equal(result['endpoint'], 'http://zammad.example.com/api/v1/form_submit') + assert(result['token']) + token = result['token'] + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: 'invalid' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'required') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, email: 'some' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'invalid') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test', body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + + travel 5.hours + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test', body: 'hello' }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + + travel 20.hours + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test', body: 'hello' }.to_json, @headers + assert_response(401) + + end + + test '04 - get config call & do submit' do + Setting.set('form_ticket_create', true) + fingerprint = SecureRandom.hex(40) + post '/api/v1/form_config', { fingerprint: fingerprint }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['enabled'], true) + assert_equal(result['endpoint'], 'http://zammad.example.com/api/v1/form_submit') + assert(result['token']) + token = result['token'] + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: 'invalid' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'required') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, email: 'some' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'invalid') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'somebody@example.com', title: 'test', body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['email'], 'invalid') + + end + + test '05 - limits' do + return if !SearchIndexBackend.enabled? + + Setting.set('form_ticket_create', true) + fingerprint = SecureRandom.hex(40) + post '/api/v1/form_config', { fingerprint: fingerprint }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['enabled'], true) + assert_equal(result['endpoint'], 'http://zammad.example.com/api/v1/form_submit') + assert(result['token']) + token = result['token'] + + (1..20).each { |count| + travel 10.seconds + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: "test#{count}", body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + Scheduler.worker(true) + sleep 1 # wait until elasticsearch is index + } + + sleep 10 # wait until elasticsearch is index + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test-last', body: 'hello' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert(result['error']) + + @headers = { 'ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json', 'REMOTE_ADDR' => '1.2.3.5' } + + (1..20).each { |count| + travel 10.seconds + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: "test-2-#{count}", body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + Scheduler.worker(true) + sleep 1 # wait until elasticsearch is index + } + + sleep 10 # wait until elasticsearch is index + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test-2-last', body: 'hello' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert(result['error']) + end + +end diff --git a/test/unit/ticket_trigger_test.rb b/test/unit/ticket_trigger_test.rb index c0c59836b..306630c8a 100644 --- a/test/unit/ticket_trigger_test.rb +++ b/test/unit/ticket_trigger_test.rb @@ -1414,6 +1414,228 @@ class TicketTriggerTest < ActiveSupport::TestCase end + test '6.1 owner auto assignment based on organization' do + trigger1 = Trigger.create_or_update( + name: 'aaa auto assignment', + condition: { + 'ticket.organization_id' => { + 'operator' => 'is not', + 'pre_condition' => 'not_set', + 'value' => '', + 'value_completion' => '', + }, + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'ticket.owner_id' => { + 'pre_condition' => 'current_user.id', + 'value' => '', + 'value_completion' => '', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + roles = Role.where(name: 'Agent') + agent = User.create_or_update( + login: 'agent@example.com', + firstname: 'Trigger', + lastname: 'Agent1', + email: 'agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + roles = Role.where(name: 'Customer') + customer = User.create_or_update( + login: 'customer@example.com', + firstname: 'Trigger', + lastname: 'Customer1', + email: 'customer@example.com', + password: 'customerpw', + vip: true, + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + + ticket1 = Ticket.create( + title: 'test 123', + group: Group.lookup(name: 'Users'), + customer: customer, + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: "some message note\nnew line", + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(1, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(1, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + + ticket1.update_attribute(:customer, User.lookup(email: 'nicole.braun@zammad.org') ) + + UserInfo.current_user_id = agent.id + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'update', + message_id: 'some@id', + content_type: 'text/html', + body: 'update', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + ) + Observer::Transaction.commit + UserInfo.current_user_id = nil + + ticket1.reload + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(agent.id, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(2, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + end + + test '6.2 owner auto assignment based on organization' do + trigger1 = Trigger.create_or_update( + name: 'aaa auto assignment', + condition: { + 'ticket.organization_id' => { + 'operator' => 'is', + 'pre_condition' => 'not_set', + 'value' => '', + 'value_completion' => '', + }, + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'ticket.owner_id' => { + 'pre_condition' => 'current_user.id', + 'value' => '', + 'value_completion' => '', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + roles = Role.where(name: 'Agent') + agent = User.create_or_update( + login: 'agent@example.com', + firstname: 'Trigger', + lastname: 'Agent1', + email: 'agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + roles = Role.where(name: 'Customer') + customer = User.create_or_update( + login: 'customer@example.com', + firstname: 'Trigger', + lastname: 'Customer1', + email: 'customer@example.com', + password: 'customerpw', + vip: true, + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + + ticket1 = Ticket.create( + title: 'test 123', + group: Group.lookup(name: 'Users'), + customer: User.lookup(email: 'nicole.braun@zammad.org'), + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: "some message note\nnew line", + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(1, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(1, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + + ticket1.update_attribute(:customer, customer ) + + UserInfo.current_user_id = agent.id + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'update', + message_id: 'some@id', + content_type: 'text/html', + body: 'update', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + ) + Observer::Transaction.commit + UserInfo.current_user_id = nil + + ticket1.reload + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(agent.id, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(2, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + end + test '7 owner auto assignment' do trigger1 = Trigger.create_or_update( name: 'aaa auto assignment', @@ -3120,9 +3342,8 @@ class TicketTriggerTest < ActiveSupport::TestCase Observer::Transaction.commit ticket1.reload - assert_equal(22, ticket1.articles.count) + assert_equal(21, ticket1.articles.count) assert_equal('some_loop_sender@example.com', ticket1.articles[20].from) - assert_equal('nicole.braun@zammad.org', ticket1.articles[21].to) Ticket::Article.create( ticket_id: ticket1.id, @@ -3141,92 +3362,8 @@ class TicketTriggerTest < ActiveSupport::TestCase Observer::Transaction.commit ticket1.reload - assert_equal(24, ticket1.articles.count) - assert_equal('some_loop_sender@example.com', ticket1.articles[22].from) - assert_equal('nicole.braun@zammad.org', ticket1.articles[23].to) - - Ticket::Article.create( - ticket_id: ticket1.id, - from: 'some_loop_sender@example.com', - to: 'some_loop_recipient@example.com', - subject: 'some subject 1234', - message_id: 'some@id', - content_type: 'text/html', - body: 'some message note
new line', - internal: false, - sender: Ticket::Article::Sender.find_by(name: 'Customer'), - type: Ticket::Article::Type.find_by(name: 'email'), - updated_by_id: 1, - created_by_id: 1, - ) - - Observer::Transaction.commit - ticket1.reload - assert_equal(26, ticket1.articles.count) - assert_equal('some_loop_sender@example.com', ticket1.articles[24].from) - assert_equal('nicole.braun@zammad.org', ticket1.articles[25].to) - - Ticket::Article.create( - ticket_id: ticket1.id, - from: 'some_loop_sender@example.com', - to: 'some_loop_recipient@example.com', - subject: 'some subject 1234', - message_id: 'some@id', - content_type: 'text/html', - body: 'some message note
new line', - internal: false, - sender: Ticket::Article::Sender.find_by(name: 'Customer'), - type: Ticket::Article::Type.find_by(name: 'email'), - updated_by_id: 1, - created_by_id: 1, - ) - - Observer::Transaction.commit - ticket1.reload - assert_equal(28, ticket1.articles.count) - assert_equal('some_loop_sender@example.com', ticket1.articles[26].from) - assert_equal('nicole.braun@zammad.org', ticket1.articles[27].to) - - Ticket::Article.create( - ticket_id: ticket1.id, - from: 'some_loop_sender@example.com', - to: 'some_loop_recipient@example.com', - subject: 'some subject 1234', - message_id: 'some@id', - content_type: 'text/html', - body: 'some message note
new line', - internal: false, - sender: Ticket::Article::Sender.find_by(name: 'Customer'), - type: Ticket::Article::Type.find_by(name: 'email'), - updated_by_id: 1, - created_by_id: 1, - ) - - Observer::Transaction.commit - ticket1.reload - assert_equal(30, ticket1.articles.count) - assert_equal('some_loop_sender@example.com', ticket1.articles[28].from) - assert_equal('nicole.braun@zammad.org', ticket1.articles[29].to) - - Ticket::Article.create( - ticket_id: ticket1.id, - from: 'some_loop_sender@example.com', - to: 'some_loop_recipient@example.com', - subject: 'some subject 1234', - message_id: 'some@id', - content_type: 'text/html', - body: 'some message note
new line', - internal: false, - sender: Ticket::Article::Sender.find_by(name: 'Customer'), - type: Ticket::Article::Type.find_by(name: 'email'), - updated_by_id: 1, - created_by_id: 1, - ) - - Observer::Transaction.commit - ticket1.reload - assert_equal(31, ticket1.articles.count) - assert_equal('some_loop_sender@example.com', ticket1.articles[30].from) + assert_equal(22, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[21].from) end diff --git a/test/unit/user_device_test.rb b/test/unit/user_device_test.rb index cd734e971..4d1ff1e52 100644 --- a/test/unit/user_device_test.rb +++ b/test/unit/user_device_test.rb @@ -204,6 +204,42 @@ class UserDeviceTest < ActiveSupport::TestCase ) assert_equal(user_device2.id, user_device6.id) + # signin without ua from country A via basic auth -> new device #3 + user_device7 = UserDevice.add( + '', + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + assert_not_equal(user_device6.id, user_device7.id) + + user_device8 = UserDevice.add( + '', + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + assert_equal(user_device7.id, user_device8.id) + + user_device9 = UserDevice.add( + nil, + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + assert_equal(user_device8.id, user_device9.id) + + user_device10 = UserDevice.add( + nil, + '176.198.137.254', + @agent.id, + nil, + 'basic_auth', + ) + assert_not_equal(user_device9.id, user_device10.id) end test 'ddd - api test' do