Merge branch 'develop' of git.znuny.com:zammad/zammad into develop

This commit is contained in:
André Bauer 2017-07-23 11:09:51 +02:00
commit 165a46450f
77 changed files with 2616 additions and 683 deletions

View file

@ -127,6 +127,17 @@ test:integration:email_deliver:
- ruby -I test/ test/integration/email_deliver_test.rb
- rake db:drop
test:integration:email_keep_on_server:
stage: test
tags:
- core
script:
- export RAILS_ENV=test
- rake db:create
- rake db:migrate
- ruby -I test/ test/integration/email_keep_on_server_test.rb
- rake db:drop
test:integration:twitter:
stage: test
tags:

View file

@ -7,7 +7,6 @@ notifications:
env:
- DB=mysql
- DB=postgresql
- BUNDLE_JOBS=8
addons:
postgresql: "9.4"
apt:

View file

@ -40,6 +40,7 @@ gem 'omniauth-gitlab'
gem 'omniauth-google-oauth2'
gem 'omniauth-linkedin-oauth2'
gem 'omniauth-twitter'
gem 'omniauth-microsoft-office365'
gem 'twitter'
gem 'telegramAPI'

View file

@ -151,10 +151,11 @@ GEM
guard (~> 2.8)
guard-compat (~> 1.0)
multi_json (~> 1.8)
guard-symlink (0.1.0)
guard-symlink (0.1.1)
guard
guard-compat (~> 1.1)
hashdiff (0.3.2)
hashie (3.4.4)
hashie (3.5.5)
htmlentities (4.3.4)
http (1.0.4)
addressable (~> 2.3)
@ -169,7 +170,7 @@ GEM
icalendar (2.4.1)
inflection (1.0.0)
json (1.8.6)
jwt (1.5.4)
jwt (1.5.6)
kgio (2.11.0)
koala (2.4.0)
addressable
@ -194,7 +195,7 @@ GEM
mini_portile2 (2.2.0)
minitest (5.10.2)
multi_json (1.12.1)
multi_xml (0.5.5)
multi_xml (0.6.0)
multipart-post (2.0.0)
mysql2 (0.4.6)
naught (1.1.0)
@ -208,33 +209,36 @@ GEM
nenv (~> 0.1)
shellany (~> 0.0)
oauth (0.5.1)
oauth2 (1.2.0)
faraday (>= 0.8, < 0.10)
oauth2 (1.4.0)
faraday (>= 0.8, < 0.13)
jwt (~> 1.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
octokit (4.4.1)
sawyer (~> 0.7.0, >= 0.5.3)
omniauth (1.3.1)
hashie (>= 1.2, < 4)
rack (>= 1.0, < 3)
omniauth (1.6.1)
hashie (>= 3.4.6, < 3.6.0)
rack (>= 1.6.2, < 3)
omniauth-facebook (4.0.0)
omniauth-oauth2 (~> 1.2)
omniauth-github (1.1.2)
omniauth (~> 1.0)
omniauth-oauth2 (~> 1.1)
omniauth-github (1.3.0)
omniauth (~> 1.5)
omniauth-oauth2 (>= 1.4.0, < 2.0)
omniauth-gitlab (1.0.2)
omniauth (~> 1.0)
omniauth-oauth2 (~> 1.0)
omniauth-google-oauth2 (0.4.1)
jwt (~> 1.5.2)
omniauth-google-oauth2 (0.5.0)
jwt (~> 1.5)
multi_json (~> 1.3)
omniauth (>= 1.1.1)
omniauth-oauth2 (>= 1.3.1)
omniauth-linkedin-oauth2 (0.1.5)
omniauth (~> 1.0)
omniauth-oauth2
omniauth-microsoft-office365 (0.0.7)
omniauth
omniauth-oauth2
omniauth-oauth (1.1.0)
oauth
omniauth (~> 1.0)
@ -463,6 +467,7 @@ DEPENDENCIES
omniauth-gitlab
omniauth-google-oauth2
omniauth-linkedin-oauth2
omniauth-microsoft-office365
omniauth-oauth2
omniauth-twitter
pg
@ -499,4 +504,4 @@ RUBY VERSION
ruby 2.3.1p112
BUNDLED WITH
1.13.7
1.15.1

View file

@ -562,19 +562,22 @@ 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: 'off', },
{ 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' },
{ name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false },
{ name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, item_class: 'formGroup--halfSize' },
{ name: 'options::keep_on_server', display: 'Keep messages on server', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false, item_class: 'formGroup--halfSize' },
]
showHideFolder = (params, attribute, attributes, classname, form, ui) ->
return if !params
if params.adapter is 'imap'
ui.show('options::folder')
ui.show('options::keep_on_server')
return
ui.hide('options::folder')
ui.hide('options::keep_on_server')
handlePort = (params, attribute, attributes, classname, form, ui) ->
return if !params
@ -609,6 +612,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal
channel_used['options']['user'] = @account['meta']['email']
channel_used['options']['password'] = @account['meta']['password']
channel_used['options']['folder'] = @account['meta']['folder']
channel_used['options']['keep_on_server'] = @account['meta']['keep_on_server']
# show used backend
@$('.base-outbound-settings').html('')
@ -670,7 +674,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal
for key, value of data.setting
@account[key] = value
if data.content_messages && data.content_messages > 0
if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true)
message = App.i18n.translateContent('We have already found %s email(s) in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages)
@$('.js-inbound-acknowledge .js-message').html(message)
@$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-intro')
@ -724,7 +728,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal
# remember account settings
@account.inbound = params
if data.content_messages && data.content_messages > 0
if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true)
message = App.i18n.translateContent('We have already found %s email(s) in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages)
@$('.js-inbound-acknowledge .js-message').html(message)
@$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-inbound')

View file

@ -9,43 +9,7 @@ class Index extends App.ControllerSubContent
@render()
render: =>
auth_provider_all = {
facebook: {
url: '/auth/facebook'
name: 'Facebook'
config: 'auth_facebook'
},
twitter: {
url: '/auth/twitter'
name: 'Twitter'
config: 'auth_twitter'
},
linkedin: {
url: '/auth/linkedin'
name: 'LinkedIn'
config: 'auth_linkedin'
},
github: {
url: '/auth/github'
name: 'GitHub'
config: 'auth_github'
},
gitlab: {
url: '/auth/gitlab'
name: 'GitLab'
config: 'auth_gitlab'
},
google_oauth2: {
url: '/auth/google_oauth2'
name: 'Google'
config: 'auth_google_oauth2'
},
oauth2: {
url: '/auth/oauth2'
name: 'OAuth2'
config: 'auth_oauth2'
},
}
auth_provider_all = App.Config.get('auth_provider_all')
auth_providers = {}
for key, provider of auth_provider_all
if @Config.get(provider.config) is true || @Config.get(provider.config) is 'true'
@ -90,3 +54,45 @@ class Index extends App.ControllerSubContent
)
App.Config.set('LinkedAccounts', { prio: 4000, name: 'Linked Accounts', parent: '#profile', target: '#profile/linked', controller: Index, permission: ['user_preferences.linked_accounts'] }, 'NavBarProfile')
App.Config.set('auth_provider_all', {
facebook:
url: '/auth/facebook'
name: 'Facebook'
config: 'auth_facebook'
class: 'facebook'
twitter:
url: '/auth/twitter'
name: 'Twitter'
config: 'auth_twitter'
class: 'twitter'
linkedin:
url: '/auth/linkedin'
name: 'LinkedIn'
config: 'auth_linkedin'
class: 'linkedin'
github:
url: '/auth/github'
name: 'GitHub'
config: 'auth_github'
class: 'github'
gitlab:
url: '/auth/gitlab'
name: 'GitLab'
config: 'auth_gitlab'
class: 'gitlab'
microsoft_office365:
url: '/auth/microsoft_office365'
name: 'Office 365'
config: 'auth_microsoft_office365'
class: 'office365'
google_oauth2:
url: '/auth/google_oauth2'
name: 'Google'
config: 'auth_google_oauth2'
class: 'google'
oauth2:
url: '/auth/oauth2'
name: 'OAuth2'
config: 'auth_oauth2'
class: 'oauth2'
})

View file

@ -82,7 +82,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
view:
shown: true
invite_customer:
show: false
shown: false
required: false
'admin.user':
create:
@ -94,10 +94,10 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
view:
shown: true
invite_agent:
show: false
shown: false
required: false
invite_customer:
show: false
shown: false
required: false
Organization:
'ticket.customer':

View file

@ -587,7 +587,7 @@ class ChatWindow extends App.Controller
@sounds.message.play()
@notifyDesktop(
title: @name
body: message
body: App.Utils.html2text(message)
url: '#customer_chat'
callback: =>
App.Event.trigger('chat_focus', { session_id: @session.session_id })

View file

@ -450,8 +450,8 @@ class EmailNotification extends App.WizardFullScreen
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::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', single: true },
{ 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: 'off', single: true },
{ name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false },
]
@form = new App.ControllerForm(
@ -673,18 +673,22 @@ class ChannelEmail extends App.WizardFullScreen
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::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true },
{ 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: 'off', 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' },
{ name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, item_class: 'formGroup--halfSize' },
{ name: 'options::keep_on_server', display: 'Keep messages on server', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false, item_class: 'formGroup--halfSize' },
]
showHideFolder = (params, attribute, attributes, classname, form, ui) ->
return if !params
if params.adapter is 'imap'
ui.show('options::folder')
ui.show('options::keep_on_server')
return
ui.hide('options::folder')
ui.hide('options::keep_on_server')
handlePort = (params, attribute, attributes, classname, form, ui) ->
return if !params
@ -700,7 +704,7 @@ class ChannelEmail extends App.WizardFullScreen
return
new App.ControllerForm(
el: @$('.base-inbound-settings'),
el: @$('.base-inbound-settings')
model:
configure_attributes: configureAttributesInbound
className: ''
@ -718,6 +722,8 @@ class ChannelEmail extends App.WizardFullScreen
if @account['meta']
channel_used['options']['user'] = @account['meta']['email']
channel_used['options']['password'] = @account['meta']['password']
channel_used['options']['folder'] = @account['meta']['folder']
channel_used['options']['keep_on_server'] = @account['meta']['keep_on_server']
# show used backend
@$('.base-outbound-settings').html('')
@ -725,8 +731,8 @@ class ChannelEmail extends App.WizardFullScreen
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::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', single: true },
{ 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: 'off', single: true },
{ name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false },
]
@form = new App.ControllerForm(
@ -745,7 +751,7 @@ class ChannelEmail extends App.WizardFullScreen
@account.meta = params
@disable(e)
@$('.js-probe .js-email').text( params.email )
@$('.js-probe .js-email').text(params.email)
@showSlide('js-probe')
@ajax(
@ -760,7 +766,7 @@ class ChannelEmail extends App.WizardFullScreen
for key, value of data.setting
@account[key] = value
if data.content_messages && data.content_messages > 0
if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true)
message = App.i18n.translateContent('We have already found %s email(s) in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages)
@$('.js-inbound-acknowledge .js-message').html(message)
@$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-intro')
@ -809,7 +815,7 @@ class ChannelEmail extends App.WizardFullScreen
# remember account settings
@account.inbound = params
if data.content_messages && data.content_messages > 0
if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true)
message = App.i18n.translateContent('We have already found %s emails in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages)
@$('.js-inbound-acknowledge .js-message').html(message)
@$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-inbound')

View file

@ -38,50 +38,7 @@ class Index extends App.ControllerContent
)
render: (data = {}) ->
auth_provider_all = {
facebook: {
url: '/auth/facebook',
name: 'Facebook',
config: 'auth_facebook',
class: 'facebook'
},
twitter: {
url: '/auth/twitter'
name: 'Twitter'
config: 'auth_twitter'
class: 'twitter'
},
linkedin: {
url: '/auth/linkedin'
name: 'LinkedIn'
config: 'auth_linkedin'
class: 'linkedin'
},
github: {
url: '/auth/github'
name: 'GitHub'
config: 'auth_github'
class: 'github'
},
gitlab: {
url: '/auth/gitlab'
name: 'GitLab'
config: 'auth_gitlab'
class: 'gitlab'
},
google_oauth2: {
url: '/auth/google_oauth2'
name: 'Google'
config: 'auth_google_oauth2'
class: 'google'
},
oauth2: {
url: '/auth/oauth2'
name: 'OAuth2'
config: 'auth_oauth2'
class: 'oauth2'
},
}
auth_provider_all = App.Config.get('auth_provider_all')
auth_providers = []
for key, provider of auth_provider_all
if @Config.get(provider.config) is true || @Config.get(provider.config) is 'true'

View file

@ -391,6 +391,7 @@ class App.TicketZoomArticleActions extends App.Controller
body = @el.closest('.ticketZoom').find('.article-add [data-name="body"]').html() || ''
# check if quote need to be added
signaturePosition = 'bottom'
selected = App.ClipBoard.getSelected('html')
if selected
selected = App.Utils.htmlCleanup(selected).html()
@ -399,6 +400,16 @@ class App.TicketZoomArticleActions extends App.Controller
if selected
selected = App.Utils.textCleanup(selected)
selected = App.Utils.text2html(selected)
# full quote, if needed
if !selected && article && App.Config.get('ui_ticket_zoom_article_email_full_quote')
signaturePosition = 'top'
if article.content_type.match('html')
selected = App.Utils.textCleanup(article.body)
if article.content_type.match('plain')
selected = App.Utils.textCleanup(selected)
selected = App.Utils.text2html(selected)
if selected
selected = "<div><br><br/></div><div><blockquote type=\"cite\">#{selected}</blockquote></div><div><br></div>"
@ -409,7 +420,12 @@ class App.TicketZoomArticleActions extends App.Controller
type = App.TicketArticleType.findByAttribute(name:'email')
App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew } )
App.Event.trigger('ui::ticket::setArticleType', {
ticket: @ticket
type: type
article: articleNew
signaturePosition: signaturePosition
})
telegramPersonalMessageReply: (e) =>
e.preventDefault()

View file

@ -59,7 +59,7 @@ class App.TicketZoomArticleNew extends App.Controller
@$('[name="' + key + '"]').val(value).trigger('change')
# preselect article type
@setArticleType(data.type.name)
@setArticleType(data.type.name, data.signaturePosition)
# set focus at end of field
if data.position is 'end'
@ -483,7 +483,7 @@ class App.TicketZoomArticleNew extends App.Controller
@$('[name=internal]').val('')
setArticleType: (type) =>
setArticleType: (type, signaturePosition = 'bottom') =>
wasScrolledToBottom = @isScrolledToBottom()
@type = type
@$('[name=type]').val(type).trigger('change')
@ -532,6 +532,9 @@ class App.TicketZoomArticleNew extends App.Controller
body.append('<br><br>')
signature = $("<div data-signature=\"true\" data-signature-id=\"#{signature.id}\">#{signatureFinished}</div>")
App.Utils.htmlStrip(signature)
if signaturePosition is 'top'
body.prepend(signature)
else
body.append(signature)
@$('[data-name=body]').replaceWith(body)
@ -566,6 +569,20 @@ class App.TicketZoomArticleNew extends App.Controller
@delay(@updateLetterCount, 600)
@$('.js-textSizeLimit').removeClass('hide')
# convert remote src images to data uri
@$('[data-name=body] img').each( (i,image) ->
$image = $(image)
src = $image.attr('src')
if !_.isEmpty(src) && !src.match(/^data:image/i)
canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
ctx = canvas.getContext('2d')
ctx.drawImage(image, 0, 0)
dataURL = canvas.toDataURL()
$image.attr('src', dataURL)
)
@scrollToBottom() if wasScrolledToBottom
isScrolledToBottom: ->

View file

@ -1,5 +1,6 @@
class SidebarOrganization extends App.Controller
sidebarItem: =>
return if !@permissionCheck('ticket.agent')
return if !@ticket.organization_id
{
head: 'Organization'

View file

@ -5,10 +5,10 @@ class Widget
banner = """
|
| Welcome Zammad Developer!
| You can enable debugging by the following examples (value is a regex):
| You can enable debugging with the following examples (value is a regex):
|
| App.Log.config('module', '(websocket|delay|interval)') // enable debugging for websocket, delay and interval class
| App.Log.config('content', 'send') // enable debugging for messages which contains the string 'send'
| App.Log.config('content', 'send') // enable debugging for messages which contain the string 'send'
| App.Log.config('banner', false) // disable this banner
|
| App.Log.config() // current settings

View file

@ -38,11 +38,16 @@ class App.PrettyDate
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
month = months[created.getMonth()]
# for less than 7 days
if diff < (60 * 60 * 24 * 7)
# for less than 6 days
# weekday HH::MM
if diff < (60 * 60 * 24 * 6)
string = "#{App.i18n.translateInline(weekday)} #{created.getHours()}:#{@s(created.getMinutes(), 2)}"
else if diff < (60 * 60 * 24 * 7) * 365
# if it was this year
# weekday DD. MM HH::MM
else if created.getYear() is current.getYear()
string = "#{App.i18n.translateInline(weekday)} #{created.getDate()}. #{App.i18n.translateInline(month)} #{created.getHours()}:#{@s(created.getMinutes(), 2)}"
# if it was the year before
# weekday YYYY-MM-DD HH::MM
else
string = "#{App.i18n.translateInline(weekday)} #{App.i18n.translateTimestamp(time)}"
if escalation

View file

@ -295,7 +295,7 @@
else {
img = "<img style=\"width: 100%; max-width: " + width + "px;\" src=\"" + result + "\">"
}
document.execCommand('insertHTML', false, img)
_this.paste(img)
}
// resize if to big
@ -367,13 +367,7 @@
text = App.Utils.removeEmptyLines(text)
_this.log('insert', text)
// as fallback, insert html via pasteHtmlAtCaret (for IE 11 and lower)
if (docType == 'text3') {
_this.pasteHtmlAtCaret(text)
}
else {
document.execCommand('insertHTML', false, text)
}
_this.paste(text)
return true
})
@ -533,37 +527,6 @@
return this.$element.html().trim()
}
// taken from https://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div/6691294#6691294
Plugin.prototype.pasteHtmlAtCaret = function(html) {
var sel, range;
if (window.getSelection) {
sel = window.getSelection()
if (sel.getRangeAt && sel.rangeCount) {
range = sel.getRangeAt(0)
range.deleteContents()
var el = document.createElement('div')
el.innerHTML = html;
var frag = document.createDocumentFragment(), node, lastNode
while ( (node = el.firstChild) ) {
lastNode = frag.appendChild(node)
}
range.insertNode(frag)
if (lastNode) {
range = range.cloneRange()
range.setStartAfter(lastNode)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
}
}
}
else if (document.selection && document.selection.type != 'Control') {
document.selection.createRange().pasteHTML(html)
}
}
// log method
Plugin.prototype.log = function() {
if (App && App.Log) {
@ -574,7 +537,30 @@
}
}
$.fn[pluginName] = function ( options ) {
// paste some content
Plugin.prototype.paste = function(string) {
var isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
// IE <= 10
if (document.selection && document.selection.createRange) {
var range = document.selection.createRange()
if (range.pasteHTML) {
range.pasteHTML(string)
}
}
// IE == 11
else if (isIE11 && document.getSelection) {
var range = document.getSelection().getRangeAt(0)
var nnode = document.createElement('div')
range.surroundContents(nnode)
nnode.innerHTML = string
}
else {
document.execCommand('insertHTML', false, string)
}
}
$.fn[pluginName] = function (options) {
return this.each(function () {
if (!$.data(this, 'plugin_' + pluginName)) {
$.data(this, 'plugin_' + pluginName,

View file

@ -250,10 +250,22 @@
// paste some content
Plugin.prototype.paste = function(string) {
if (document.selection) { // IE
var isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
// IE <= 10
if (document.selection && document.selection.createRange) {
var range = document.selection.createRange()
if (range.pasteHTML) {
range.pasteHTML(string)
}
}
// IE == 11
else if (isIE11 && document.getSelection) {
var range = document.getSelection().getRangeAt(0)
var nnode = document.createElement('div')
range.surroundContents(nnode)
nnode.innerHTML = string
}
else {
document.execCommand('insertHTML', false, string)
}
@ -295,14 +307,7 @@
// for chrome, insert space again
if (start) {
if (spacerChar === ' ') {
string = "&nbsp;"
if (document.selection) { // IE
var range = document.selection.createRange()
range.pasteHTML(string)
}
else {
document.execCommand('insertHTML', false, string)
}
this.paste('&nbsp;')
}
}
}

View file

@ -32,6 +32,10 @@ class App.Model extends Spine.Model
return @title
if @subject
return @subject
if @phone
return @phone
if @login
return @login
return '???'
displayNameLong: ->
@ -57,6 +61,12 @@ class App.Model extends Spine.Model
return @email
if @title
return @title
if @subject
return @subject
if @phone
return @phone
if @login
return @login
return '???'
icon: (user) ->
@ -165,6 +175,31 @@ class App.Model extends Spine.Model
###
set new attributes of model (remove already available attributes)
App.Model.attributesSet(attributes)
###
@attributesSet: (attributes) ->
configure_attributes = App[ @.className ].configure_attributes
attributesNew = []
for localAttribute in configure_attributes
found = false
for attribute in attributes
if attribute.name is localAttribute.name
found = true
break
if !found
attributesNew.push localAttribute
for attribute in attributes
App[@.className].attributes.push attribute.name
attributesNew.push attribute
App[ @.className ].configure_attributes = attributesNew
###
attributes = App.Model.attributesGet(optionalScreen, optionalAttributesList)
returns

View file

@ -63,7 +63,7 @@
</form>
<form class="setup wizard hide js-inbound">
<div class="wizard-slide">
<div class="wizard-slide wizard-slide--large">
<h2><%- @T('Email Inbound') %></h2>
<div class="wizard-body vertical justified">
<div class="alert alert--danger hide" role="alert"></div>

View file

@ -51,6 +51,10 @@
<label for="id1">Name</label>
<input id="id1" class="form-control" type="text" placeholder="Text Input">
</div>
<div class="input form-group">
<label for="id1">Name (readonly)</label>
<input id="id1" class="form-control" type="text" placeholder="Text Input" readonly value="Sandor Clegane">
</div>
<div class="input form-group">
<label for="id2">Password</label>
<input id="id2" class="form-control" type="password" value="Password Input">

View file

@ -116,8 +116,8 @@
</div>
<div class="searchfield">
<%- @Icon('magnifier') %>
<input class="js-search form-control" name="search" placeholder="Search for users" type="search">
<%- @Icon('magnifier') %>
</div>
<div class="userSearch horizontal">

View file

@ -24,7 +24,7 @@
<div class="formGroup-label">
<label for="password"><%- @Ti('Password') %></label>
</div>
<input id="password" name="password" type="password" class="form-control"/>
<input id="password" name="password" type="password" class="form-control" autocomplete="off"/>
</div>
<div class="form-group">

View file

@ -3,8 +3,8 @@
<div class="detail-search">
<div class="detail-search-header">
<div class="searchfield">
<%- @Icon('magnifier') %>
<input class="js-search form-control<%= if !@query then ' is-empty' %>" name="query" placeholder="<%- @Ti('Find what you search. E. g. "search phrase"') %>" value="<%= @query %>" type="search" autocomplete="off">
<%- @Icon('magnifier') %>
<div class="empty-search js-emptySearch">
<%- @Icon('diagonal-cross') %>
</div>

View file

@ -9,6 +9,7 @@
<div class="ticketZoom">
<div class="ticketZoom-controls">
<div class="js-settingContainer"></div>
<div class="spacer"></div>
<div class="js-highlighterContainer highlighter"></div>
<div class="js-overviewNavigatorContainer overview-navigator"></div>
</div>

View file

@ -1,3 +1,3 @@
<div class="btn btn--action btn--split--first js-setting centered">
<div class="btn btn--action js-setting centered">
<%- @Icon('cog', 'dropdown-icon') %>
</div>

View file

@ -12,8 +12,8 @@
</div>
<div class="page-content">
<div class="searchfield">
<%- @Icon('magnifier') %>
<input class="js-search form-control" name="search" placeholder="<%- @Ti('Search for users') %>" type="search">
<%- @Icon('magnifier') %>
</div>
<div class="userSearch horizontal">

View file

@ -55,6 +55,7 @@
.icon-mute { width: 16px; height: 16px; }
.icon-note { width: 16px; height: 16px; }
.icon-oauth2-button { width: 29px; height: 24px; }
.icon-office365-button { width: 29px; height: 24px; }
.icon-one-ticket { width: 48px; height: 10px; }
.icon-organization { width: 16px; height: 16px; }
.icon-outbound-calls { width: 17px; height: 17px; }

View file

@ -398,7 +398,8 @@ pre code.hljs {
}
&.is-disabled,
&[disabled] {
&[disabled],
&:disabled {
pointer-events: none;
cursor: not-allowed;
opacity: .33;
@ -415,7 +416,7 @@ pre code.hljs {
font-size: 12px;
letter-spacing: 0.05em;
height: 31px;
padding: 2px 11px 0 !important;
padding: 0 11px !important;
display: inline-flex;
align-items: center;
@ -1623,6 +1624,24 @@ textarea,
border-color: hsl(200,71%,59%);
box-shadow: 0 0 0 3px hsl(201,62%,90%);
}
&.is-disabled, // .is-disabled should not be used - legacy support
&[disabled],
&[readonly] {
background: hsl(210,17%,93%);
border-color: hsl(210,10%,85%);
&:focus,
&.focus {
border-color: hsl(200,71%,59%);
}
}
&.is-disabled, // .is-disabled should not be used
&[disabled] {
cursor: not-allowed;
opacity: 1;
}
}
input[type=url] {
@ -1700,13 +1719,6 @@ select.form-control:not([multiple]) {
display: none;
}
.form-control[disabled], .form-control.is-disabled {
cursor: not-allowed;
background-color: #fff;
color: #d5d5d5;
opacity: 1;
}
.form-control.form-control--borderless {
border: none;
padding: 0;
@ -1860,6 +1872,7 @@ input.has-error {
appearance: textfield;
border-radius: 19px;
padding: 0 17px 0 42px;
will-change: transform;
&.is-empty + .empty-search {
visibility: hidden;
@ -2550,6 +2563,10 @@ ol.tabs li {
background: hsl(0,0%,15%);
}
&.auth-provider--office365 {
background: hsl(15,100%,47%);
}
.provider-name {
flex: 1;
}
@ -2558,7 +2575,6 @@ ol.tabs li {
width: 29px;
height: 24px;
margin-right: 10px;
margin-top: 1px;
}
}
@ -3956,7 +3972,6 @@ footer {
max-width: 400px;
min-width: 350px;
flex-direction: column;
@extend .zIndex-2;
&.is-visible {
display: flex;
@ -5483,8 +5498,13 @@ footer {
.newTicket .sidebar {
width: 290px;
}
.newTicket .form-control:not(:focus):not(.focus) {
.newTicket .form-control {
border-color: hsl(0,0%,90%);
&:focus,
&.focus {
border-color: hsl(200,71%,59%);
}
}
.newTicket .article-form-top {
margin-top: 15px;
@ -5964,6 +5984,8 @@ footer {
}
.dropdown-menu {
@extend .zIndex-5; // has to be behind modal windows and beneath notifications (popover)
position: absolute;
margin: 0;
padding: 0;
min-width: 100%;
@ -6303,7 +6325,9 @@ footer {
cursor: default;
}
.checkbox.form-group .controls label {
.checkbox,
.radio {
&.form-group .controls label {
padding: 2px 0;
font: inherit;
font-size: 13px;
@ -6312,6 +6336,7 @@ footer {
text-transform: inherit;
letter-spacing: 0;
@extend .u-clickable;
}
}
.userSearch-label {
@ -6440,6 +6465,9 @@ footer {
@extend .u-textTruncate;
}
.wizard {
margin: auto; // makes sure that the wizard is scrollable
}
.wizard-logo {
fill: white;
@ -6454,6 +6482,10 @@ footer {
width: 400px;
padding-bottom: 18px;
margin-bottom: 20px;
&.wizard-slide--large {
width: 460px;
}
}
.wizard h2 {
@ -7178,6 +7210,12 @@ output {
.zammad-switch input {
display: none;
&[disabled] + label {
cursor: not-allowed;
background: hsl(210,17%,93%);
border-color: hsl(210,10%,85%);
}
&:focus + label {
transition: none;
background: hsl(200,71%,59%);

View file

@ -126,6 +126,16 @@ class UsersController < ApplicationController
if admin_account_exists && !params[:signup]
raise Exceptions::UnprocessableEntity, 'Only signup with not authenticate user possible!'
end
# check if user already exists
if clean_params[:email].blank?
raise Exceptions::UnprocessableEntity, 'Attribute \'email\' required!'
end
# check if user already exists
exists = User.find_by(email: clean_params[:email].downcase.strip)
raise Exceptions::UnprocessableEntity, 'Email address is already used for other user.' if exists
user = User.new(clean_params)
user.associations_from_param(params)
user.updated_by_id = 1
@ -165,11 +175,6 @@ class UsersController < ApplicationController
user.associations_from_param(params)
end
# check if user already exists
if !user.email.empty?
exists = User.where(email: user.email.downcase).first
raise Exceptions::UnprocessableEntity, 'User already exists!' if exists
end
user.save!
# if first user was added, set system init done
@ -177,7 +182,7 @@ class UsersController < ApplicationController
Setting.set('system_init_done', true)
# fetch org logo
if !user.email.empty?
if user.email.present?
Service::Image.organization_suggest(user.email)
end
@ -252,17 +257,17 @@ class UsersController < ApplicationController
# only allow Admin's
if current_user.permissions?('admin.user') && (params[:role_ids] || params[:roles])
user.associations_from_param({ role_ids: params[:role_ids], roles: params[:roles] })
user.associations_from_param(role_ids: params[:role_ids], roles: params[:roles])
end
# only allow Admin's
if current_user.permissions?('admin.user') && (params[:group_ids] || params[:groups])
user.associations_from_param({ group_ids: params[:group_ids], groups: params[:groups] })
user.associations_from_param(group_ids: params[:group_ids], groups: params[:groups])
end
# only allow Admin's and Agent's
if current_user.permissions?(['admin.user', 'ticket.agent']) && (params[:organization_ids] || params[:organizations])
user.associations_from_param({ organization_ids: params[:organization_ids], organizations: params[:organizations] })
user.associations_from_param(organization_ids: params[:organization_ids], organizations: params[:organizations])
end
if params[:expand]
@ -363,7 +368,7 @@ class UsersController < ApplicationController
limit: params[:limit],
current_user: current_user,
}
if params[:role_ids] && !params[:role_ids].empty?
if params[:role_ids].present?
query_params[:role_ids] = params[:role_ids]
end
@ -449,10 +454,10 @@ class UsersController < ApplicationController
end
# do query
user_all = if params[:role_ids] && !params[:role_ids].empty?
User.joins(:roles).where( 'roles.id' => params[:role_ids] ).where('users.id != 1').order('users.created_at DESC').limit( params[:limit] || 20 )
user_all = if params[:role_ids].present?
User.joins(:roles).where('roles.id' => params[:role_ids]).where('users.id != 1').order('users.created_at DESC').limit(params[:limit] || 20)
else
User.where('id != 1').order('created_at DESC').limit( params[:limit] || 20 )
User.where('id != 1').order('created_at DESC').limit(params[:limit] || 20)
end
# build result list

View file

@ -5,7 +5,7 @@ class ApplicationModel < ActiveRecord::Base
include ApplicationModel::HasCache
include ApplicationModel::CanLookup
include ApplicationModel::CanLookupSearchIndexAttributes
include ApplicationModel::ChecksAttributeLength
include ApplicationModel::ChecksAttributeValuesAndLength
include ApplicationModel::CanCleanupParam
include ApplicationModel::HasRecentViews
include ApplicationModel::ChecksUserColumnsFillup

View file

@ -1,39 +0,0 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module ApplicationModel::ChecksAttributeLength
extend ActiveSupport::Concern
included do
before_create :check_attribute_length
before_update :check_attribute_length
end
=begin
check string/varchar size and cut them if needed
=end
def check_attribute_length
attributes.each { |attribute|
next if !self[ attribute[0] ]
next if !self[ attribute[0] ].instance_of?(String)
next if self[ attribute[0] ].empty?
column = self.class.columns_hash[ attribute[0] ]
next if !column
limit = column.limit
if column && limit
current_length = attribute[1].to_s.length
if limit < current_length
logger.warn "WARNING: cut string because of database length #{self.class}.#{attribute[0]}(#{limit} but is #{current_length}:#{attribute[1]})"
self[ attribute[0] ] = attribute[1][ 0, limit ]
end
end
# strip 4 bytes utf8 chars if needed
if column && self[ attribute[0] ]
self[attribute[0]] = self[ attribute[0] ].utf8_to_3bytesutf8
end
}
true
end
end

View file

@ -0,0 +1,54 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module ApplicationModel::ChecksAttributeValuesAndLength
extend ActiveSupport::Concern
included do
before_create :check_attribute_values_and_length
before_update :check_attribute_values_and_length
end
=begin
1) check string/varchar size and cut them if needed
2) check string for null byte \u0000 and remove it
=end
def check_attribute_values_and_length
columns = self.class.columns_hash
attributes.each { |name, value|
next if value.blank?
next if !value.instance_of?(String)
column = columns[name]
next if !column
# strip null byte chars (postgresql will complain about it)
if column.sql_type == 'text'
if Rails.application.config.db_null_byte == false
self[name].delete!("\u0000")
end
end
# for varchar check length and replace null bytes
limit = column.limit
if limit
current_length = value.length
if limit < current_length
logger.warn "WARNING: cut string because of database length #{self.class}.#{name}(#{limit} but is #{current_length}:#{value})"
self[name] = value[0, limit]
end
# strip null byte chars (postgresql will complain about it)
if Rails.application.config.db_null_byte == false
self[name].delete!("\u0000")
end
end
# strip 4 bytes utf8 chars if needed (mysql/mariadb will complain it)
next if self[name].blank?
self[name] = self[name].utf8_to_3bytesutf8
}
true
end
end

View file

@ -52,6 +52,7 @@ example
host: 'outlook.office365.com',
user: 'xxx@znuny.onmicrosoft.com',
password: 'xxx',
keep_on_server: true,
}
channel = Channel.last
instance = Channel::Driver::Imap.new
@ -62,11 +63,16 @@ example
def fetch (options, channel, check_type = '', verify_string = '')
ssl = true
port = 993
keep_on_server = false
folder = 'INBOX'
if options[:keep_on_server] == true || options[:keep_on_server] == 'true'
keep_on_server = true
end
if options.key?(:ssl) && options[:ssl] == false
ssl = false
port = 143
end
if options.key?(:port) && !options[:port].empty?
if options.key?(:port) && options[:port].present?
port = options[:port]
# disable ssl for non ssl ports
@ -74,8 +80,11 @@ example
ssl = false
end
end
if options[:folder].present?
folder = options[:folder]
end
Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl},folder=#{options[:folder]})"
Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl},folder=#{folder},keep_on_server=#{keep_on_server})"
# on check, reduce open_timeout to have faster probing
timeout = 45
@ -90,17 +99,17 @@ example
@imap.login(options[:user], options[:password])
# select folder
if !options[:folder] || options[:folder].empty?
@imap.select('INBOX')
else
@imap.select(options[:folder])
end
@imap.select(folder)
# sort messages by date on server (if not supported), if not fetch messages via search (first in, first out)
filter = ['ALL']
if keep_on_server && check_type != 'check' && check_type != 'verify'
filter = %w(NOT SEEN)
end
begin
message_ids = @imap.sort(['DATE'], ['ALL'], 'US-ASCII')
message_ids = @imap.sort(['DATE'], filter, 'US-ASCII')
rescue
message_ids = @imap.search(['ALL'])
message_ids = @imap.search(filter)
end
# check mode only
@ -168,9 +177,8 @@ example
message_ids.each do |message_id|
count += 1
Rails.logger.info " - message #{count}/#{count_all}"
#Rails.logger.info msg.to_s
message_meta = @imap.fetch(message_id, ['RFC822.SIZE', 'FLAGS', 'INTERNALDATE'])[0]
message_meta = @imap.fetch(message_id, ['RFC822.SIZE', 'ENVELOPE', 'FLAGS', 'INTERNALDATE'])[0]
# ignore to big messages
info = too_big?(message_meta, count, count_all)
@ -182,14 +190,23 @@ example
# ignore deleted messages
next if deleted?(message_meta, count, count_all)
# ignore already imported
next if already_imported?(message_id, message_meta, count, count_all, keep_on_server)
# delete email from server after article was created
msg = @imap.fetch(message_id, 'RFC822')[0].attr['RFC822']
next if !msg
process(channel, msg, false)
if !keep_on_server
@imap.store(message_id, '+FLAGS', [:Deleted])
else
@imap.store(message_id, '+FLAGS', [:Seen])
end
count_fetched += 1
end
if !keep_on_server
@imap.expunge()
end
disconnect
if count.zero?
Rails.logger.info ' - no message'
@ -209,6 +226,20 @@ example
private
def already_imported?(message_id, message_meta, count, count_all, keep_on_server)
return false if !keep_on_server
return false if !message_meta.attr
return false if !message_meta.attr['ENVELOPE']
local_message_id = message_meta.attr['ENVELOPE'].message_id
return false if local_message_id.blank?
local_message_id_md5 = Digest::MD5.hexdigest(local_message_id)
article = Ticket::Article.where(message_id_md5: local_message_id_md5).order('created_at DESC, id DESC').limit(1).first
return false if !article
@imap.store(message_id, '+FLAGS', [:Seen])
Rails.logger.info " - ignore message #{count}/#{count_all} - because message message id already imported"
true
end
def deleted?(message_meta, count, count_all)
return false if !message_meta.attr['FLAGS'].include?(:Deleted)
Rails.logger.info " - ignore message #{count}/#{count_all} - because message has already delete flag"

View file

@ -156,7 +156,9 @@ Check if string is a complete html document. If not, add head and css styles.
return html if html =~ /<html>/i
Rails.configuration.html_email_body.sub('###html###', html)
# use block form because variable html could contain backslashes and e. g. '\1' that
# must not be handled as back-references for regular expressions
Rails.configuration.html_email_body.sub('###html###') { html }
end
=begin

View file

@ -358,5 +358,23 @@ returns
)
}
end
=begin
cleanup caller logs
Cti::Log.cleanup
optional you can put the max oldest chat entries as argument
Cti::Log.cleanup(12.months)
=end
def self.cleanup(diff = 12.months)
Cti::Log.where('created_at < ?', Time.zone.now - diff).delete_all
true
end
end
end

View file

@ -28,24 +28,37 @@ class Overview < ApplicationModel
end
def fill_link_on_create
return true if !link.empty?
return true if link.present?
self.link = link_name(name)
true
end
def fill_link_on_update
return true if link.empty?
return true if !changes['name']
return true if changes['link']
self.link = link_name(name)
true
end
def link_name(name)
link = name.downcase
link.gsub!(/\s/, '_')
link.gsub!(/[^0-9a-z]/i, '_')
link.gsub!(/_+/, '_')
link
local_link = name.downcase
local_link = local_link.parameterize('_')
local_link.gsub!(/\s/, '_')
local_link.gsub!(/_+/, '_')
local_link = URI.escape(local_link)
if local_link.blank?
local_link = id || rand(999)
end
check = true
while check
exists = Overview.find_by(link: local_link)
if exists && exists.id != id
local_link = "#{local_link}_#{rand(999)}"
else
check = false
end
end
local_link
end
end

View file

@ -2,7 +2,11 @@
class Signature < ApplicationModel
include ChecksLatestChangeObserved
include ChecksHtmlSanitized
has_many :groups, after_add: :cache_update, after_remove: :cache_update
validates :name, presence: true
sanitized_html :body
end

View file

@ -2,10 +2,13 @@
class TextModule < ApplicationModel
include ChecksClientNotification
include ChecksHtmlSanitized
validates :name, presence: true
validates :content, presence: true
sanitized_html :content
=begin
load text modules from online

View file

@ -38,7 +38,7 @@ class User < ApplicationModel
load 'user/search_index.rb'
include User::SearchIndex
before_validation :check_name, :check_email, :check_login, :ensure_password
before_validation :check_name, :check_email, :check_login, :ensure_uniq_email, :ensure_password, :ensure_roles, :ensure_identifier
before_create :check_preferences_default, :validate_roles, :domain_based_assignment, :set_locale
before_update :check_preferences_default, :validate_roles, :reset_login_failed
after_create :avatar_for_email_check
@ -845,7 +845,7 @@ returns
def check_email
return true if Setting.get('import_mode')
return true if email.empty?
return true if email.blank?
self.email = email.downcase.strip
return true if id == 1
raise Exceptions::UnprocessableEntity, 'Invalid email' if email !~ /@/
@ -867,9 +867,9 @@ returns
end
end
# if no email, complain about missing login
if id != 1 && login.blank?
raise Exceptions::UnprocessableEntity, 'Attribute \'login\' required!'
# generate auto login
if login.blank?
self.login = "auto-#{Time.zone.now.to_i}-#{rand(999_999)}"
end
# check if login already exists
@ -878,7 +878,7 @@ returns
while check
exists = User.find_by(login: login)
if exists && exists.id != id
self.login = login + rand(999).to_s
self.login = "#{login}#{rand(999)}"
else
check = false
end
@ -886,6 +886,27 @@ returns
true
end
def ensure_roles
return true if role_ids.present?
self.role_ids = Role.signup_role_ids
end
def ensure_identifier
return true if email.present? || firstname.present? || lastname.present? || phone.present?
return true if login.present? && !login.start_with?('auto-')
raise Exceptions::UnprocessableEntity, 'Minimum one identifier (login, firstname, lastname, phone or email) for user is required.'
end
def ensure_uniq_email
return true if Setting.get('user_email_multiple_use')
return true if Setting.get('import_mode')
return true if email.blank?
return true if !changes
return true if !changes['email']
return true if !User.find_by(email: email.downcase.strip)
raise Exceptions::UnprocessableEntity, 'Email address is already used for other user.'
end
def validate_roles
return true if !role_ids
role_ids.each { |role_id|

View file

@ -1,6 +1,7 @@
# mysql
if ActiveRecord::Base.connection_config[:adapter] == 'mysql2'
Rails.application.config.db_4bytes_utf8 = false
Rails.application.config.db_null_byte = true
# mysql version check
# mysql example: "5.7.3"

View file

@ -2,6 +2,7 @@
if ActiveRecord::Base.connection_config[:adapter] == 'postgresql'
Rails.application.config.db_case_sensitive = true
Rails.application.config.db_like = 'ILIKE'
Rails.application.config.db_null_byte = false
# postgresql version check
# example output: "9.5.0"

View file

@ -32,9 +32,11 @@ Rails.application.config.middleware.use OmniAuth::Builder do
authorize_url: '/oauth/authorize',
token_url: '/oauth/token'
},
scope: 'read_user',
}
# microsoft_office365 database connect
provider :microsoft_office365_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database'
# oauth2 database connect
provider :oauth2_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database', {
client_options: {

Binary file not shown.

View file

@ -33,6 +33,7 @@ server {
proxy_set_header Connection "Upgrade";
proxy_set_header CLIENT_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_pass http://zammad-websocket;
}
@ -41,6 +42,7 @@ server {
proxy_set_header Host $http_host;
proxy_set_header CLIENT_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 180;
proxy_pass http://zammad;

View file

@ -124,6 +124,7 @@ server {
proxy_set_header Connection "Upgrade";
proxy_set_header CLIENT_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_pass http://zammad-websocket;
}
@ -132,6 +133,7 @@ server {
proxy_set_header Host $http_host;
proxy_set_header CLIENT_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 180;
proxy_pass http://zammad;

View file

@ -0,0 +1,63 @@
class OmniauthOffice365Setting < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
Setting.create_if_not_exists(
title: 'Authentication via %s',
name: 'auth_microsoft_office365',
area: 'Security::ThirdPartyAuthentication',
description: 'Enables user authentication via %s. Register your app first at [%s](%s).',
options: {
form: [
{
display: '',
null: true,
name: 'auth_microsoft_office365',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
preferences: {
controller: 'SettingsAreaSwitch',
sub: ['auth_microsoft_office365_credentials'],
title_i18n: ['Office 365'],
description_i18n: ['Office 365', 'Microsoft Application Registration Portal', 'https://apps.dev.microsoft.com'],
permission: ['admin.security'],
},
state: false,
frontend: true
)
Setting.create_if_not_exists(
title: 'Office 365 App Credentials',
name: 'auth_microsoft_office365_credentials',
area: 'Security::ThirdPartyAuthentication::Office365',
description: 'Enables user authentication via Office 365.',
options: {
form: [
{
display: 'App ID',
null: true,
name: 'app_id',
tag: 'input',
},
{
display: 'App Secret',
null: true,
name: 'app_secret',
tag: 'input',
},
],
},
state: {},
preferences: {
permission: ['admin.security'],
},
frontend: false
)
end
end

View file

@ -1,4 +1,4 @@
class TicketZoomSetting < ActiveRecord::Migration
class TicketZoomSetting2 < ActiveRecord::Migration
def up
# return if it's a new setup
@ -67,6 +67,33 @@ class TicketZoomSetting < ActiveRecord::Migration
},
frontend: true
)
Setting.create_if_not_exists(
title: 'Email - full quote',
name: 'ui_ticket_zoom_article_email_full_quote',
area: 'UI::TicketZoom',
description: 'Enable if you want to quote the full email in your answer. The quoted email will be put at the end of your answer. If you just want to quote a certain phrase, just mark the text and press reply (this feature is always available).',
options: {
form: [
{
display: '',
null: true,
name: 'ui_ticket_zoom_article_email_full_quote',
tag: 'boolean',
translate: true,
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: false,
preferences: {
prio: 220,
permission: ['admin.ui'],
},
frontend: true
)
Setting.create_if_not_exists(
title: 'Twitter - tweet initials',
name: 'ui_ticket_zoom_article_twitter_initials',

View file

@ -0,0 +1,54 @@
class ObjectManagerUserEmailOptional < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
ObjectManager::Attribute.add(
force: true,
object: 'User',
name: 'email',
display: 'Email',
data_type: 'input',
data_option: {
type: 'email',
maxlength: 150,
null: true,
item_class: 'formGroup--halfSize',
},
editable: false,
active: true,
screens: {
signup: {
'-all-' => {
null: false,
},
},
invite_agent: {
'-all-' => {
null: false,
},
},
invite_customer: {
'-all-' => {
null: false,
},
},
edit: {
'-all-' => {
null: true,
},
},
view: {
'-all-' => {
shown: true,
},
},
},
to_create: false,
to_migrate: false,
to_delete: false,
position: 400,
)
end
end

View file

@ -0,0 +1,34 @@
class UserEmailMultipleUse < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
Setting.create_if_not_exists(
title: 'User email for muliple users',
name: 'user_email_multiple_use',
area: 'Model::User',
description: 'Allow to use email address for muliple users.',
options: {
form: [
{
display: '',
null: true,
name: 'user_email_multiple_use',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: false,
preferences: {
permission: ['admin'],
},
frontend: false
)
end
end

View file

@ -0,0 +1,18 @@
class CleanupCtiLog < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
Scheduler.create_if_not_exists(
name: 'Cleanup Cti::Log',
method: 'Cti::Log.cleanup',
period: 1.month,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
end
end

View file

@ -604,7 +604,7 @@ ObjectManager::Attribute.add(
data_option: {
type: 'email',
maxlength: 150,
null: false,
null: true,
item_class: 'formGroup--halfSize',
},
editable: false,
@ -627,7 +627,7 @@ ObjectManager::Attribute.add(
},
edit: {
'-all-' => {
null: false,
null: true,
},
},
view: {

View file

@ -156,6 +156,15 @@ Scheduler.create_if_not_exists(
updated_by_id: 1,
created_by_id: 1,
)
Scheduler.create_if_not_exists(
name: 'Cleanup Cti::Log',
method: 'Cti::Log.cleanup',
period: 1.month,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
Scheduler.create_if_not_exists(
name: 'Import Jobs',
method: 'ImportJob.start_registered',

View file

@ -600,6 +600,33 @@ Setting.create_if_not_exists(
},
frontend: true
)
Setting.create_if_not_exists(
title: 'Email - full quote',
name: 'ui_ticket_zoom_article_email_full_quote',
area: 'UI::TicketZoom',
description: 'Enable if you want to quote the full email in your answer. The quoted email will be put at the end of your answer. If you just want to quote a certain phrase, just mark the text and press reply (this feature is always available).',
options: {
form: [
{
display: '',
null: true,
name: 'ui_ticket_zoom_article_email_full_quote',
tag: 'boolean',
translate: true,
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: false,
preferences: {
prio: 220,
permission: ['admin.ui'],
},
frontend: true
)
Setting.create_if_not_exists(
title: 'Twitter - tweet initials',
name: 'ui_ticket_zoom_article_twitter_initials',
@ -678,6 +705,31 @@ Setting.create_if_not_exists(
},
frontend: true
)
Setting.create_if_not_exists(
title: 'User email for muliple users',
name: 'user_email_multiple_use',
area: 'Model::User',
description: 'Allow to use email address for muliple users.',
options: {
form: [
{
display: '',
null: true,
name: 'user_email_multiple_use',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: false,
preferences: {
permission: ['admin'],
},
frontend: false
)
Setting.create_if_not_exists(
title: 'Authentication via %s',
name: 'auth_ldap',
@ -1057,6 +1109,63 @@ Setting.create_if_not_exists(
frontend: false
)
Setting.create_if_not_exists(
title: 'Authentication via %s',
name: 'auth_microsoft_office365',
area: 'Security::ThirdPartyAuthentication',
description: 'Enables user authentication via %s. Register your app first at [%s](%s).',
options: {
form: [
{
display: '',
null: true,
name: 'auth_microsoft_office365',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
preferences: {
controller: 'SettingsAreaSwitch',
sub: ['auth_microsoft_office365_credentials'],
title_i18n: ['Office 365'],
description_i18n: ['Office 365', 'Microsoft Application Registration Portal', 'https://apps.dev.microsoft.com'],
permission: ['admin.security'],
},
state: false,
frontend: true
)
Setting.create_if_not_exists(
title: 'Office 365 App Credentials',
name: 'auth_microsoft_office365_credentials',
area: 'Security::ThirdPartyAuthentication::Office365',
description: 'Enables user authentication via Office 365.',
options: {
form: [
{
display: 'App ID',
null: true,
name: 'app_id',
tag: 'input',
},
{
display: 'App Secret',
null: true,
name: 'app_secret',
tag: 'input',
},
],
},
state: {},
preferences: {
permission: ['admin.security'],
},
frontend: false
)
Setting.create_if_not_exists(
title: 'Authentication via %s',
name: 'auth_oauth2',
@ -1116,7 +1225,7 @@ Setting.create_if_not_exists(
null: true,
name: 'site',
tag: 'input',
placeholder: 'https://gitlab.YOURDOMAIN.com',
placeholder: 'https://oauth.YOURDOMAIN.com',
},
{
display: 'authorize_url',

View file

@ -81,7 +81,7 @@ class String
def html2text(string_only = false, strict = false)
string = "#{self}" # rubocop:disable Style/UnneededInterpolation
# in case of invalid encodeing, strip invalid chars
# in case of invalid encoding, strip invalid chars
# see also test/fixtures/mail21.box
# note: string.encode!('UTF-8', 'UTF-8', :invalid => :replace, :replace => '?') was not detecting invalid chars
if !string.valid_encoding?

View file

@ -66,6 +66,7 @@ class Stats::TicketReopen
def self.log(object, o_id, changes, updated_by_id)
return if object != 'Ticket'
ticket = Ticket.lookup(id: o_id)
return if !ticket
# check if close_at is already set / if not, ticket is not reopend
return if !ticket.close_at

View file

@ -197,6 +197,23 @@ do($ = window.jQuery, window) ->
'Since you didn\'t respond in the last %s minutes your conversation with <strong>%s</strong> got closed.': 'Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit <strong>%s</strong> 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!'
'es'
'<strong>Chat</strong> with us!': '<strong>Chatee</strong> con nosotros!'
'Scroll down to see new messages': 'Haga scroll hacia abajo para ver nuevos mensajes'
'Online': 'En linea'
'Online': 'En linea'
'Offline': 'Desconectado'
'Connecting': 'Conectando'
'Connection re-established': 'Conexión restablecida'
'Today': 'Hoy'
'Send': 'Enviar'
'Compose your message...': 'Escriba su mensaje...'
'All colleagues are busy.': 'Todos los agentes están ocupados.'
'You are on waiting list position <strong>%s</strong>.': 'Usted está en la posición <strong>%s</strong> de la lista de espera.'
'Start new conversation': 'Iniciar nueva conversación'
'Since you didn\'t respond in the last %s minutes your conversation with <strong>%s</strong> got closed.': 'Puesto que usted no respondió en los últimos %s minutos su conversación con <strong>%s</strong> se ha cerrado.'
'Since you didn\'t respond in the last %s minutes your conversation got closed.': 'Puesto que usted no respondió en los últimos %s minutos su conversación se ha cerrado.'
'We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!': 'Lo sentimos, se tarda más tiempo de lo esperado para ser atendido por un agente. Inténtelo de nuevo más tarde o envíenos un correo electrónico. ¡Gracias!'
'fr':
'<strong>Chat</strong> with us!': '<strong>Chattez</strong> avec nous!'
'Scroll down to see new messages': 'Faites défiler pour lire les nouveaux messages'
@ -251,6 +268,11 @@ do($ = window.jQuery, window) ->
sessionId: undefined
scrolledToBottom: true
scrollSnapTolerance: 10
richTextFormatKey:
66: true # b
73: true # i
85: true # u
83: true # s
T: (string, items...) =>
if @options.lang && @options.lang isnt 'en'
@ -367,9 +389,211 @@ do($ = window.jQuery, window) ->
@el.find('.zammad-chat-controls').on 'submit', @onSubmit
@el.find('.zammad-chat-body').on 'scroll', @detectScrolledtoBottom
@el.find('.zammad-scroll-hint').click @onScrollHintClick
@input.on
@input.on(
keydown: @checkForEnter
input: @onInput
)
@input.on('keydown', (e) =>
richtTextControl = false
if !e.altKey && !e.ctrlKey && e.metaKey
richtTextControl = true
else if !e.altKey && e.ctrlKey && !e.metaKey
richtTextControl = true
if richtTextControl && @richTextFormatKey[ e.keyCode ]
e.preventDefault()
if e.keyCode is 66
document.execCommand('bold')
return true
if e.keyCode is 73
document.execCommand('italic')
return true
if e.keyCode is 85
document.execCommand('underline')
return true
if e.keyCode is 83
document.execCommand('strikeThrough')
return true
)
@input.on('paste', (e) =>
e.stopPropagation()
e.preventDefault()
clipboardData
if e.clipboardData
clipboardData = e.clipboardData
else if window.clipboardData
clipboardData = window.clipboardData
else if e.originalEvent.clipboardData
clipboardData = e.originalEvent.clipboardData
else
throw 'No clipboardData support'
imageInserted = false
if clipboardData && clipboardData.items && clipboardData.items[0]
item = clipboardData.items[0]
if item.kind == 'file' && (item.type == 'image/png' || item.type == 'image/jpeg')
imageFile = item.getAsFile()
reader = new FileReader()
reader.onload = (e) =>
result = e.target.result
img = document.createElement('img')
img.src = result
insert = (dataUrl, width, height, isRetina) =>
# adapt image if we are on retina devices
if @isRetina()
width = width / 2
height = height / 2
result = dataUrl
img = "<img style=\"width: 100%; max-width: #{width}px;\" src=\"#{result}\">"
document.execCommand('insertHTML', false, img)
# resize if to big
@resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert)
reader.readAsDataURL(imageFile)
imageInserted = true
return if imageInserted
# check existing + paste text for limit
text = undefined
docType = undefined
try
text = clipboardData.getData('text/html')
docType = 'html'
if !text || text.length is 0
docType = 'text'
text = clipboardData.getData('text/plain')
if !text || text.length is 0
docType = 'text2'
text = clipboardData.getData('text')
catch e
console.log('Sorry, can\'t insert markup because browser is not supporting it.')
docType = 'text3'
text = clipboardData.getData('text')
if docType is 'text' || docType is 'text2' || docType is 'text3'
text = '<div>' + text.replace(/\n/g, '</div><div>') + '</div>'
text = text.replace(/<div><\/div>/g, '<div><br></div>')
console.log('p', docType, text)
if docType is 'html'
html = $("<div>#{text}</div>")
match = false
htmlTmp = text
regex = new RegExp('<(/w|w)\:[A-Za-z]')
if htmlTmp.match(regex)
match = true
htmlTmp = htmlTmp.replace(regex, '')
regex = new RegExp('<(/o|o)\:[A-Za-z]')
if htmlTmp.match(regex)
match = true
htmlTmp = htmlTmp.replace(regex, '')
if match
html = @wordFilter(html)
#html
html = $(html)
html.contents().each( ->
if @nodeType == 8
$(@).remove()
)
# remove tags, keep content
html.find('a, font, small, time, form, label').replaceWith( ->
$(@).contents()
)
# replace tags with generic div
# New type of the tag
replacementTag = 'div';
# Replace all x tags with the type of replacementTag
html.find('textarea').each( ->
outer = @outerHTML
# Replace opening tag
regex = new RegExp('<' + @tagName, 'i')
newTag = outer.replace(regex, '<' + replacementTag)
# Replace closing tag
regex = new RegExp('</' + @tagName, 'i')
newTag = newTag.replace(regex, '</' + replacementTag)
$(@).replaceWith(newTag)
)
# remove tags & content
html.find('font, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head, fieldset').remove()
@removeAttributes(html)
text = html.html()
# as fallback, insert html via pasteHtmlAtCaret (for IE 11 and lower)
if docType is 'text3'
@pasteHtmlAtCaret(text)
else
document.execCommand('insertHTML', false, text)
true
)
@input.on('drop', (e) =>
e.stopPropagation()
e.preventDefault()
dataTransfer
if window.dataTransfer # ie
dataTransfer = window.dataTransfer
else if e.originalEvent.dataTransfer # other browsers
dataTransfer = e.originalEvent.dataTransfer
else
throw 'No clipboardData support'
x = e.clientX
y = e.clientY
file = dataTransfer.files[0]
# look for images
if file.type.match('image.*')
reader = new FileReader()
reader.onload = (e) =>
result = e.target.result
img = document.createElement('img')
img.src = result
# Insert the image at the carat
insert = (dataUrl, width, height, isRetina) =>
# adapt image if we are on retina devices
if @isRetina()
width = width / 2
height = height / 2
result = dataUrl
img = $("<img style=\"width: 100%; max-width: #{width}px;\" src=\"#{result}\">")
img = img.get(0)
if document.caretPositionFromPoint
pos = document.caretPositionFromPoint(x, y)
range = document.createRange()
range.setStart(pos.offsetNode, pos.offset)
range.collapse()
range.insertNode(img)
else if document.caretRangeFromPoint
range = document.caretRangeFromPoint(x, y)
range.insertNode(img)
else
console.log('could not find carat')
# resize if to big
@resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert)
reader.readAsDataURL(file)
)
$(window).on('beforeunload', =>
@onLeaveTemporary()
)
@ -471,7 +695,7 @@ do($ = window.jQuery, window) ->
from: if message.created_by_id then 'agent' else 'customer'
if unfinishedMessage
@input.val unfinishedMessage
@input.html(unfinishedMessage)
# show wait list
if data.position
@ -489,7 +713,7 @@ do($ = window.jQuery, window) ->
@el.find('.zammad-chat-message--unread')
.removeClass 'zammad-chat-message--unread'
sessionStorage.setItem 'unfinished_message', @input.val()
sessionStorage.setItem 'unfinished_message', @input.html()
@onTyping()
@ -520,7 +744,7 @@ do($ = window.jQuery, window) ->
@sendMessage()
sendMessage: ->
message = @input.val()
message = @input.html()
return if !message
@inactiveTimeout.start()
@ -543,7 +767,7 @@ do($ = window.jQuery, window) ->
@lastAddedType = 'message--customer'
@el.find('.zammad-chat-body').append messageElement
@input.val('')
@input.html('')
@scrollToBottom()
# send message event
@ -585,11 +809,6 @@ do($ = window.jQuery, window) ->
@el.addClass('zammad-chat-is-open')
if !@inputInitialized
@inputInitialized = true
@input.autoGrow
extraLine: false
remainerHeight = @el.height() - @el.find('.zammad-chat-header').outerHeight()
@el.css 'bottom', -remainerHeight
@ -1032,4 +1251,204 @@ do($ = window.jQuery, window) ->
else if direction is 'horizontal'
return !!clientSize && ((compareRight <= viewRight) && (compareLeft >= viewLeft))
isRetina: ->
if window.matchMedia
mq = window.matchMedia('only screen and (min--moz-device-pixel-ratio: 1.3), only screen and (-o-min-device-pixel-ratio: 2.6/2), only screen and (-webkit-min-device-pixel-ratio: 1.3), only screen and (min-device-pixel-ratio: 1.3), only screen and (min-resolution: 1.3dppx)')
return (mq && mq.matches || (window.devicePixelRatio > 1))
false
resizeImage: (dataURL, x = 'auto', y = 'auto', sizeFactor = 1, type, quallity, callback, force = true) ->
# load image from data url
imageObject = new Image()
imageObject.onload = ->
imageWidth = imageObject.width
imageHeight = imageObject.height
console.log('ImageService', 'current size', imageWidth, imageHeight)
if y is 'auto' && x is 'auto'
x = imageWidth
y = imageHeight
# get auto dimensions
if y is 'auto'
factor = imageWidth / x
y = imageHeight / factor
if x is 'auto'
factor = imageWidth / y
x = imageHeight / factor
# check if resize is needed
resize = false
if x < imageWidth || y < imageHeight
resize = true
x = x * sizeFactor
y = y * sizeFactor
else
x = imageWidth
y = imageHeight
# create canvas and set dimensions
canvas = document.createElement('canvas')
canvas.width = x
canvas.height = y
# draw image on canvas and set image dimensions
context = canvas.getContext('2d')
context.drawImage(imageObject, 0, 0, x, y)
# set quallity based on image size
if quallity == 'auto'
if x < 200 && y < 200
quallity = 1
else if x < 400 && y < 400
quallity = 0.9
else if x < 600 && y < 600
quallity = 0.8
else if x < 900 && y < 900
quallity = 0.7
else
quallity = 0.6
# execute callback with resized image
newDataUrl = canvas.toDataURL(type, quallity)
if resize
console.log('ImageService', 'resize', x/sizeFactor, y/sizeFactor, quallity, (newDataUrl.length * 0.75)/1024/1024, 'in mb')
callback(newDataUrl, x/sizeFactor, y/sizeFactor, true)
return
console.log('ImageService', 'no resize', x, y, quallity, (newDataUrl.length * 0.75)/1024/1024, 'in mb')
callback(newDataUrl, x, y, false)
# load image from data url
imageObject.src = dataURL
# taken from https://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div/6691294#6691294
pasteHtmlAtCaret: (html) ->
sel = undefined
range = undefined
if window.getSelection
sel = window.getSelection()
if sel.getRangeAt && sel.rangeCount
range = sel.getRangeAt(0)
range.deleteContents()
el = document.createElement('div')
el.innerHTML = html
frag = document.createDocumentFragment(node, lastNode)
while node = el.firstChild
lastNode = frag.appendChild(node)
range.insertNode(frag)
if lastNode
range = range.cloneRange()
range.setStartAfter(lastNode)
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
else if document.selection && document.selection.type != 'Control'
document.selection.createRange().pasteHTML(html)
# (C) sbrin - https://github.com/sbrin
# https://gist.github.com/sbrin/6801034
wordFilter: (editor) ->
content = editor.html()
# Word comments like conditional comments etc
content = content.replace(/<!--[\s\S]+?-->/gi, '')
# Remove comments, scripts (e.g., msoShowComment), XML tag, VML content,
# MS Office namespaced tags, and a few other tags
content = content.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi, '')
# Convert <s> into <strike> for line-though
content = content.replace(/<(\/?)s>/gi, '<$1strike>')
# Replace nbsp entites to char since it's easier to handle
# content = content.replace(/&nbsp;/gi, "\u00a0")
content = content.replace(/&nbsp;/gi, ' ')
# Convert <span style="mso-spacerun:yes">___</span> to string of alternating
# breaking/non-breaking spaces of same length
#content = content.replace(/<span\s+style\s*=\s*"\s*mso-spacerun\s*:\s*yes\s*;?\s*"\s*>([\s\u00a0]*)<\/span>/gi, (str, spaces) ->
# return (spaces.length > 0) ? spaces.replace(/./, " ").slice(Math.floor(spaces.length/2)).split("").join("\u00a0") : ''
#)
editor.html(content)
# Parse out list indent level for lists
$('p', editor).each( ->
str = $(@).attr('style')
matches = /mso-list:\w+ \w+([0-9]+)/.exec(str)
if matches
$(@).data('_listLevel', parseInt(matches[1], 10))
)
# Parse Lists
last_level = 0
pnt = null
$('p', editor).each(->
cur_level = $(@).data('_listLevel')
if cur_level != undefined
txt = $(@).text()
list_tag = '<ul></ul>'
if (/^\s*\w+\./.test(txt))
matches = /([0-9])\./.exec(txt)
if matches
start = parseInt(matches[1], 10)
list_tag = start>1 ? '<ol start="' + start + '"></ol>' : '<ol></ol>'
else
list_tag = '<ol></ol>'
if cur_level > last_level
if last_level == 0
$(@).before(list_tag)
pnt = $(@).prev()
else
pnt = $(list_tag).appendTo(pnt)
if cur_level < last_level
for i in [i..last_level-cur_level]
pnt = pnt.parent()
$('span:first', @).remove()
pnt.append('<li>' + $(@).html() + '</li>')
$(@).remove()
last_level = cur_level
else
last_level = 0
)
$('[style]', editor).removeAttr('style')
$('[align]', editor).removeAttr('align')
$('span', editor).replaceWith(->
$(@).contents()
)
$('span:empty', editor).remove()
$("[class^='Mso']", editor).removeAttr('class')
$('p:empty', editor).remove()
editor
removeAttribute: (element) ->
return if !element
$element = $(element)
for att in element.attributes
if att && att.name
element.removeAttribute(att.name)
#$element.removeAttr(att.name)
$element.removeAttr('style')
.removeAttr('class')
.removeAttr('lang')
.removeAttr('type')
.removeAttr('align')
.removeAttr('id')
.removeAttr('wrap')
.removeAttr('title')
removeAttributes: (html, parent = true) =>
if parent
html.each((index, element) => @removeAttribute(element) )
html.find('*').each((index, element) => @removeAttribute(element) )
html
window.ZammadChat = ZammadChat

View file

@ -320,9 +320,9 @@
.zammad-chat-controls {
overflow: hidden;
display: none;
-webkit-align-items: flex-start;
-ms-flex-align: start;
align-items: flex-start;
-webkit-align-items: flex-end;
-ms-flex-align: end;
align-items: flex-end;
border-top: 1px solid #ededed;
padding: 0;
margin: 0;
@ -340,25 +340,23 @@
margin: 0;
padding: 1em 2em;
float: left;
width: auto;
height: auto;
max-height: 6em;
min-height: 1.4em !important;
min-height: 1.4em;
font-family: inherit;
line-height: 1.4em;
font-size: inherit;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: none !important;
border: none;
background: none;
box-shadow: none !important;
box-shadow: none;
box-sizing: content-box;
outline: none;
resize: none;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1; }
flex: 1;
overflow: auto; }
.zammad-chat-input::-webkit-input-placeholder {
color: #d9d9d9; }
@ -373,7 +371,7 @@
background: #379ad7;
color: white;
padding: 0.5em 1.2em;
margin: 0.5em 1em 0.5em;
margin: 0.63em 1em;
cursor: pointer;
border: none;
border-radius: 1.5em;

View file

@ -1,3 +1,64 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
}
(function() {
(function() {
if (this.agent.avatar) {
__out.push('\n<img class="zammad-chat-agent-avatar" src="');
__out.push(__sanitize(this.agent.avatar));
__out.push('">\n');
}
__out.push('\n<span class="zammad-chat-agent-sentence">\n <span class="zammad-chat-agent-name">');
__out.push(__sanitize(this.agent.name));
__out.push('</span>\n</span>');
}).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; },
@ -60,7 +121,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
};
Log.prototype.log = function(level, items) {
var i, item, len, logString;
var item, j, len, logString;
items.unshift('||');
items.unshift(level);
items.unshift(this.options.logPrefix);
@ -69,8 +130,8 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
return;
}
logString = '';
for (i = 0, len = items.length; i < len; i++) {
item = items[i];
for (j = 0, len = items.length; j < len; j++) {
item = items[j];
logString += ' ';
if (typeof item === 'object') {
logString += JSON.stringify(item);
@ -173,11 +234,11 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
})(this);
this.ws.onmessage = (function(_this) {
return function(e) {
var i, len, pipe, pipes;
var j, len, pipe, pipes;
pipes = JSON.parse(e.data);
_this.log.debug('onMessage', e.data);
for (i = 0, len = pipes.length; i < len; i++) {
pipe = pipes[i];
for (j = 0, len = pipes.length; j < len; j++) {
pipe = pipes[j];
if (pipe.event === 'pong') {
_this.ping();
}
@ -386,8 +447,15 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
ZammadChat.prototype.scrollSnapTolerance = 10;
ZammadChat.prototype.richTextFormatKey = {
66: true,
73: true,
85: true,
83: true
};
ZammadChat.prototype.T = function() {
var i, item, items, len, string, translations;
var item, items, j, len, string, translations;
string = arguments[0], items = 2 <= arguments.length ? slice.call(arguments, 1) : [];
if (this.options.lang && this.options.lang !== 'en') {
if (!this.translations[this.options.lang]) {
@ -401,8 +469,8 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
}
}
if (items) {
for (i = 0, len = items.length; i < len; i++) {
item = items[i];
for (j = 0, len = items.length; j < len; j++) {
item = items[j];
string = string.replace(/%s/, item);
}
}
@ -425,6 +493,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
};
function ZammadChat(options) {
this.removeAttributes = bind(this.removeAttributes, this);
this.startTimeoutObservers = bind(this.startTimeoutObservers, this);
this.onCssLoaded = bind(this.onCssLoaded, this);
this.setAgentOnlineState = bind(this.setAgentOnlineState, this);
@ -552,6 +621,203 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
keydown: this.checkForEnter,
input: this.onInput
});
this.input.on('keydown', (function(_this) {
return function(e) {
var richtTextControl;
richtTextControl = false;
if (!e.altKey && !e.ctrlKey && e.metaKey) {
richtTextControl = true;
} else if (!e.altKey && e.ctrlKey && !e.metaKey) {
richtTextControl = true;
}
if (richtTextControl && _this.richTextFormatKey[e.keyCode]) {
e.preventDefault();
if (e.keyCode === 66) {
document.execCommand('bold');
return true;
}
if (e.keyCode === 73) {
document.execCommand('italic');
return true;
}
if (e.keyCode === 85) {
document.execCommand('underline');
return true;
}
if (e.keyCode === 83) {
document.execCommand('strikeThrough');
return true;
}
}
};
})(this));
this.input.on('paste', (function(_this) {
return function(e) {
var clipboardData, docType, error, html, htmlTmp, imageFile, imageInserted, item, match, reader, regex, replacementTag, text;
e.stopPropagation();
e.preventDefault();
clipboardData;
if (e.clipboardData) {
clipboardData = e.clipboardData;
} else if (window.clipboardData) {
clipboardData = window.clipboardData;
} else if (e.originalEvent.clipboardData) {
clipboardData = e.originalEvent.clipboardData;
} else {
throw 'No clipboardData support';
}
imageInserted = false;
if (clipboardData && clipboardData.items && clipboardData.items[0]) {
item = clipboardData.items[0];
if (item.kind === 'file' && (item.type === 'image/png' || item.type === 'image/jpeg')) {
imageFile = item.getAsFile();
reader = new FileReader();
reader.onload = function(e) {
var img, insert, result;
result = e.target.result;
img = document.createElement('img');
img.src = result;
insert = function(dataUrl, width, height, isRetina) {
if (_this.isRetina()) {
width = width / 2;
height = height / 2;
}
result = dataUrl;
img = "<img style=\"width: 100%; max-width: " + width + "px;\" src=\"" + result + "\">";
return document.execCommand('insertHTML', false, img);
};
return _this.resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert);
};
reader.readAsDataURL(imageFile);
imageInserted = true;
}
}
if (imageInserted) {
return;
}
text = void 0;
docType = void 0;
try {
text = clipboardData.getData('text/html');
docType = 'html';
if (!text || text.length === 0) {
docType = 'text';
text = clipboardData.getData('text/plain');
}
if (!text || text.length === 0) {
docType = 'text2';
text = clipboardData.getData('text');
}
} catch (error) {
e = error;
console.log('Sorry, can\'t insert markup because browser is not supporting it.');
docType = 'text3';
text = clipboardData.getData('text');
}
if (docType === 'text' || docType === 'text2' || docType === 'text3') {
text = '<div>' + text.replace(/\n/g, '</div><div>') + '</div>';
text = text.replace(/<div><\/div>/g, '<div><br></div>');
}
console.log('p', docType, text);
if (docType === 'html') {
html = $("<div>" + text + "</div>");
match = false;
htmlTmp = text;
regex = new RegExp('<(/w|w)\:[A-Za-z]');
if (htmlTmp.match(regex)) {
match = true;
htmlTmp = htmlTmp.replace(regex, '');
}
regex = new RegExp('<(/o|o)\:[A-Za-z]');
if (htmlTmp.match(regex)) {
match = true;
htmlTmp = htmlTmp.replace(regex, '');
}
if (match) {
html = _this.wordFilter(html);
}
html = $(html);
html.contents().each(function() {
if (this.nodeType === 8) {
return $(this).remove();
}
});
html.find('a, font, small, time, form, label').replaceWith(function() {
return $(this).contents();
});
replacementTag = 'div';
html.find('textarea').each(function() {
var newTag, outer;
outer = this.outerHTML;
regex = new RegExp('<' + this.tagName, 'i');
newTag = outer.replace(regex, '<' + replacementTag);
regex = new RegExp('</' + this.tagName, 'i');
newTag = newTag.replace(regex, '</' + replacementTag);
return $(this).replaceWith(newTag);
});
html.find('font, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head, fieldset').remove();
_this.removeAttributes(html);
text = html.html();
}
if (docType === 'text3') {
_this.pasteHtmlAtCaret(text);
} else {
document.execCommand('insertHTML', false, text);
}
return true;
};
})(this));
this.input.on('drop', (function(_this) {
return function(e) {
var dataTransfer, file, reader, x, y;
e.stopPropagation();
e.preventDefault();
dataTransfer;
if (window.dataTransfer) {
dataTransfer = window.dataTransfer;
} else if (e.originalEvent.dataTransfer) {
dataTransfer = e.originalEvent.dataTransfer;
} else {
throw 'No clipboardData support';
}
x = e.clientX;
y = e.clientY;
file = dataTransfer.files[0];
if (file.type.match('image.*')) {
reader = new FileReader();
reader.onload = function(e) {
var img, insert, result;
result = e.target.result;
img = document.createElement('img');
img.src = result;
insert = function(dataUrl, width, height, isRetina) {
var pos, range;
if (_this.isRetina()) {
width = width / 2;
height = height / 2;
}
result = dataUrl;
img = $("<img style=\"width: 100%; max-width: " + width + "px;\" src=\"" + result + "\">");
img = img.get(0);
if (document.caretPositionFromPoint) {
pos = document.caretPositionFromPoint(x, y);
range = document.createRange();
range.setStart(pos.offsetNode, pos.offset);
range.collapse();
return range.insertNode(img);
} else if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(x, y);
return range.insertNode(img);
} else {
return console.log('could not find carat');
}
};
return _this.resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert);
};
return reader.readAsDataURL(file);
}
};
})(this));
$(window).on('beforeunload', (function(_this) {
return function() {
return _this.onLeaveTemporary();
@ -595,9 +861,9 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
};
ZammadChat.prototype.onWebSocketMessage = function(pipes) {
var i, len, pipe;
for (i = 0, len = pipes.length; i < len; i++) {
pipe = pipes[i];
var j, len, pipe;
for (j = 0, len = pipes.length; j < len; j++) {
pipe = pipes[j];
this.log.debug('ws:onmessage', pipe);
switch (pipe.event) {
case 'chat_error':
@ -683,15 +949,15 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
};
ZammadChat.prototype.onReopenSession = function(data) {
var i, len, message, ref, unfinishedMessage;
var j, len, message, ref, unfinishedMessage;
this.log.debug('old messages', data.session);
this.inactiveTimeout.start();
unfinishedMessage = sessionStorage.getItem('unfinished_message');
if (data.agent) {
this.onConnectionEstablished(data);
ref = data.session;
for (i = 0, len = ref.length; i < len; i++) {
message = ref[i];
for (j = 0, len = ref.length; j < len; j++) {
message = ref[j];
this.renderMessage({
message: message.content,
id: message.id,
@ -699,7 +965,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
});
}
if (unfinishedMessage) {
this.input.val(unfinishedMessage);
this.input.html(unfinishedMessage);
}
}
if (data.position) {
@ -715,7 +981,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
ZammadChat.prototype.onInput = function() {
this.el.find('.zammad-chat-message--unread').removeClass('zammad-chat-message--unread');
sessionStorage.setItem('unfinished_message', this.input.val());
sessionStorage.setItem('unfinished_message', this.input.html());
return this.onTyping();
};
@ -749,7 +1015,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
ZammadChat.prototype.sendMessage = function() {
var message, messageElement;
message = this.input.val();
message = this.input.html();
if (!message) {
return;
}
@ -769,7 +1035,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
this.lastAddedType = 'message--customer';
this.el.find('.zammad-chat-body').append(messageElement);
}
this.input.val('');
this.input.html('');
this.scrollToBottom();
return this.send('chat_session_message', {
content: message,
@ -810,12 +1076,6 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
this.showLoader();
}
this.el.addClass('zammad-chat-is-open');
if (!this.inputInitialized) {
this.inputInitialized = true;
this.input.autoGrow({
extraLine: false
});
}
remainerHeight = this.el.height() - this.el.find('.zammad-chat-header').outerHeight();
this.el.css('bottom', -remainerHeight);
if (!this.sessionId) {
@ -1328,165 +1588,223 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments);
}
};
ZammadChat.prototype.isRetina = function() {
var mq;
if (window.matchMedia) {
mq = window.matchMedia('only screen and (min--moz-device-pixel-ratio: 1.3), only screen and (-o-min-device-pixel-ratio: 2.6/2), only screen and (-webkit-min-device-pixel-ratio: 1.3), only screen and (min-device-pixel-ratio: 1.3), only screen and (min-resolution: 1.3dppx)');
return mq && mq.matches || (window.devicePixelRatio > 1);
}
return false;
};
ZammadChat.prototype.resizeImage = function(dataURL, x, y, sizeFactor, type, quallity, callback, force) {
var imageObject;
if (x == null) {
x = 'auto';
}
if (y == null) {
y = 'auto';
}
if (sizeFactor == null) {
sizeFactor = 1;
}
if (force == null) {
force = true;
}
imageObject = new Image();
imageObject.onload = function() {
var canvas, context, factor, imageHeight, imageWidth, newDataUrl, resize;
imageWidth = imageObject.width;
imageHeight = imageObject.height;
console.log('ImageService', 'current size', imageWidth, imageHeight);
if (y === 'auto' && x === 'auto') {
x = imageWidth;
y = imageHeight;
}
if (y === 'auto') {
factor = imageWidth / x;
y = imageHeight / factor;
}
if (x === 'auto') {
factor = imageWidth / y;
x = imageHeight / factor;
}
resize = false;
if (x < imageWidth || y < imageHeight) {
resize = true;
x = x * sizeFactor;
y = y * sizeFactor;
} else {
x = imageWidth;
y = imageHeight;
}
canvas = document.createElement('canvas');
canvas.width = x;
canvas.height = y;
context = canvas.getContext('2d');
context.drawImage(imageObject, 0, 0, x, y);
if (quallity === 'auto') {
if (x < 200 && y < 200) {
quallity = 1;
} else if (x < 400 && y < 400) {
quallity = 0.9;
} else if (x < 600 && y < 600) {
quallity = 0.8;
} else if (x < 900 && y < 900) {
quallity = 0.7;
} else {
quallity = 0.6;
}
}
newDataUrl = canvas.toDataURL(type, quallity);
if (resize) {
console.log('ImageService', 'resize', x / sizeFactor, y / sizeFactor, quallity, (newDataUrl.length * 0.75) / 1024 / 1024, 'in mb');
callback(newDataUrl, x / sizeFactor, y / sizeFactor, true);
return;
}
console.log('ImageService', 'no resize', x, y, quallity, (newDataUrl.length * 0.75) / 1024 / 1024, 'in mb');
return callback(newDataUrl, x, y, false);
};
return imageObject.src = dataURL;
};
ZammadChat.prototype.pasteHtmlAtCaret = function(html) {
var el, frag, lastNode, node, range, sel;
sel = void 0;
range = void 0;
if (window.getSelection) {
sel = window.getSelection();
if (sel.getRangeAt && sel.rangeCount) {
range = sel.getRangeAt(0);
range.deleteContents();
el = document.createElement('div');
el.innerHTML = html;
frag = document.createDocumentFragment(node, lastNode);
while (node = el.firstChild) {
lastNode = frag.appendChild(node);
}
range.insertNode(frag);
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
sel.removeAllRanges();
return sel.addRange(range);
}
}
} else if (document.selection && document.selection.type !== 'Control') {
return document.selection.createRange().pasteHTML(html);
}
};
ZammadChat.prototype.wordFilter = function(editor) {
var content, last_level, pnt;
content = editor.html();
content = content.replace(/<!--[\s\S]+?-->/gi, '');
content = content.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi, '');
content = content.replace(/<(\/?)s>/gi, '<$1strike>');
content = content.replace(/&nbsp;/gi, ' ');
editor.html(content);
$('p', editor).each(function() {
var matches, str;
str = $(this).attr('style');
matches = /mso-list:\w+ \w+([0-9]+)/.exec(str);
if (matches) {
return $(this).data('_listLevel', parseInt(matches[1], 10));
}
});
last_level = 0;
pnt = null;
$('p', editor).each(function() {
var cur_level, i, j, list_tag, matches, ref, ref1, ref2, start, txt;
cur_level = $(this).data('_listLevel');
if (cur_level !== void 0) {
txt = $(this).text();
list_tag = '<ul></ul>';
if (/^\s*\w+\./.test(txt)) {
matches = /([0-9])\./.exec(txt);
if (matches) {
start = parseInt(matches[1], 10);
list_tag = (ref = start > 1) != null ? ref : '<ol start="' + start + {
'"></ol>': '<ol></ol>'
};
} else {
list_tag = '<ol></ol>';
}
}
if (cur_level > last_level) {
if (last_level === 0) {
$(this).before(list_tag);
pnt = $(this).prev();
} else {
pnt = $(list_tag).appendTo(pnt);
}
}
if (cur_level < last_level) {
for (i = j = ref1 = i, ref2 = last_level - cur_level; ref1 <= ref2 ? j <= ref2 : j >= ref2; i = ref1 <= ref2 ? ++j : --j) {
pnt = pnt.parent();
}
}
$('span:first', this).remove();
pnt.append('<li>' + $(this).html() + '</li>');
$(this).remove();
return last_level = cur_level;
} else {
return last_level = 0;
}
});
$('[style]', editor).removeAttr('style');
$('[align]', editor).removeAttr('align');
$('span', editor).replaceWith(function() {
return $(this).contents();
});
$('span:empty', editor).remove();
$("[class^='Mso']", editor).removeAttr('class');
$('p:empty', editor).remove();
return editor;
};
ZammadChat.prototype.removeAttribute = function(element) {
var $element, att, j, len, ref;
if (!element) {
return;
}
$element = $(element);
ref = element.attributes;
for (j = 0, len = ref.length; j < len; j++) {
att = ref[j];
if (att && att.name) {
element.removeAttribute(att.name);
}
}
return $element.removeAttr('style').removeAttr('class').removeAttr('lang').removeAttr('type').removeAttr('align').removeAttr('id').removeAttr('wrap').removeAttr('title');
};
ZammadChat.prototype.removeAttributes = function(html, parent) {
if (parent == null) {
parent = true;
}
if (parent) {
html.each((function(_this) {
return function(index, element) {
return _this.removeAttribute(element);
};
})(this));
}
html.find('*').each((function(_this) {
return function(index, element) {
return _this.removeAttribute(element);
};
})(this));
return html;
};
return ZammadChat;
})(Base);
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
}
(function() {
(function() {
if (this.agent.avatar) {
__out.push('\n<img class="zammad-chat-agent-avatar" src="');
__out.push(__sanitize(this.agent.avatar));
__out.push('">\n');
}
__out.push('\n<span class="zammad-chat-agent-sentence">\n <span class="zammad-chat-agent-name">');
__out.push(__sanitize(this.agent.name));
__out.push('</span>\n</span>');
}).call(this);
}).call(__obj);
__obj.safe = __objSafe, __obj.escape = __escape;
return __out.join('');
};
/*!
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* <jevin9@gmail.com> wrote this file. As long as you retain this notice you
* can do whatever you want with this stuff. If we meet some day, and you think
* this stuff is worth it, you can buy me a beer in return. Jevin O. Sewaruth
* ----------------------------------------------------------------------------
*
* Autogrow Textarea Plugin Version v3.0
* http://www.technoreply.com/autogrow-textarea-plugin-3-0
*
* THIS PLUGIN IS DELIVERD ON A PAY WHAT YOU WHANT BASIS. IF THE PLUGIN WAS USEFUL TO YOU, PLEASE CONSIDER BUYING THE PLUGIN HERE :
* https://sites.fastspring.com/technoreply/instant/autogrowtextareaplugin
*
* Date: October 15, 2012
*
* Zammad modification:
* - remove overflow:hidden when maximum height is reached
* - mirror box-sizing
*
*/
jQuery.fn.autoGrow = function(options) {
return this.each(function() {
var settings = jQuery.extend({
extraLine: true,
}, options);
var createMirror = function(textarea) {
jQuery(textarea).after('<div class="autogrow-textarea-mirror"></div>');
return jQuery(textarea).next('.autogrow-textarea-mirror')[0];
}
var sendContentToMirror = function (textarea) {
mirror.innerHTML = String(textarea.value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/ /g, '&nbsp;')
.replace(/\n/g, '<br />') +
(settings.extraLine? '.<br/>.' : '')
;
if (jQuery(textarea).height() != jQuery(mirror).height()) {
jQuery(textarea).height(jQuery(mirror).height());
var overflow = jQuery(mirror).height() > maxHeight ? '' : 'hidden';
jQuery(textarea).css('overflow', overflow);
}
}
var growTextarea = function () {
sendContentToMirror(this);
}
// Create a mirror
var mirror = createMirror(this);
// Store max-height
var maxHeight = parseInt(jQuery(this).css('max-height'), 10);
// Style the mirror
mirror.style.display = 'none';
mirror.style.wordWrap = 'break-word';
mirror.style.whiteSpace = 'normal';
mirror.style.padding = jQuery(this).css('paddingTop') + ' ' +
jQuery(this).css('paddingRight') + ' ' +
jQuery(this).css('paddingBottom') + ' ' +
jQuery(this).css('paddingLeft');
mirror.style.width = jQuery(this).css('width');
mirror.style.fontFamily = jQuery(this).css('font-family');
mirror.style.fontSize = jQuery(this).css('font-size');
mirror.style.lineHeight = jQuery(this).css('line-height');
mirror.style.letterSpacing = jQuery(this).css('letter-spacing');
mirror.style.boxSizing = jQuery(this).css('boxSizing');
// Style the textarea
this.style.overflow = "hidden";
this.style.minHeight = this.rows+"em";
// Bind the textarea's event
this.onkeyup = growTextarea;
this.onfocus = growTextarea;
// Fire the event for text already present
sendContentToMirror(this);
});
};
if (!window.zammadChatTemplates) {
window.zammadChatTemplates = {};
}
@ -1555,11 +1873,11 @@ window.zammadChatTemplates["chat"] = function (__obj) {
__out.push(this.T(this.scrollHint));
__out.push('\n </div>\n <div class="zammad-chat-body"></div>\n <form class="zammad-chat-controls">\n <textarea class="zammad-chat-input" rows="1" placeholder="');
__out.push('\n </div>\n <div class="zammad-chat-body"></div>\n <form class="zammad-chat-controls">\n <div class="zammad-chat-input" rows="1" placeholder="');
__out.push(this.T('Compose your message...'));
__out.push('"></textarea>\n <button type="submit" class="zammad-chat-button zammad-chat-send"');
__out.push('" contenteditable="true"></div>\n <button type="submit" class="zammad-chat-button zammad-chat-send"');
if (this.background) {
__out.push(__sanitize(" style='background: " + this.background + "'"));

File diff suppressed because one or more lines are too long

View file

@ -329,7 +329,7 @@
.zammad-chat-controls {
overflow: hidden;
display: none;
align-items: flex-start;
align-items: flex-end;
border-top: 1px solid hsl(0,0%,93%);
padding: 0;
margin: 0;
@ -349,21 +349,19 @@
margin: 0;
padding: 1em 2em;
float: left;
width: auto;
height: auto;
max-height: 6em;
min-height: 1.4em !important;
min-height: 1.4em;
font-family: inherit;
line-height: 1.4em;
font-size: inherit;
appearance: none;
border: none !important;
border: none;
background: none;
box-shadow: none !important;
box-shadow: none ;
box-sizing: content-box;
outline: none;
resize: none;
flex: 1;
overflow: auto;
}
.zammad-chat-input::-webkit-input-placeholder {
@ -378,7 +376,7 @@
background: hsl(203,67%,53%);
color: white;
padding: 0.5em 1.2em;
margin: 0.5em 1em 0.5em;
margin: 0.63em 1em;
cursor: pointer;
border: none;
border-radius: 1.5em;

View file

@ -29,9 +29,7 @@ gulp.task('js', function(){
.pipe(plumber())
.pipe(coffee({bare: true}).on('error', gutil.log));
var autoGrow = gulp.src('jquery.autoGrow.js');
return merge(templates, js, autoGrow)
return merge(templates, js)
.pipe(concat('chat.js'))
.pipe(gulp.dest('./'))
.pipe(uglify())

View file

@ -1,92 +0,0 @@
/*!
* ----------------------------------------------------------------------------
* "THE BEER-WARE LICENSE" (Revision 42):
* <jevin9@gmail.com> wrote this file. As long as you retain this notice you
* can do whatever you want with this stuff. If we meet some day, and you think
* this stuff is worth it, you can buy me a beer in return. Jevin O. Sewaruth
* ----------------------------------------------------------------------------
*
* Autogrow Textarea Plugin Version v3.0
* http://www.technoreply.com/autogrow-textarea-plugin-3-0
*
* THIS PLUGIN IS DELIVERD ON A PAY WHAT YOU WHANT BASIS. IF THE PLUGIN WAS USEFUL TO YOU, PLEASE CONSIDER BUYING THE PLUGIN HERE :
* https://sites.fastspring.com/technoreply/instant/autogrowtextareaplugin
*
* Date: October 15, 2012
*
* Zammad modification:
* - remove overflow:hidden when maximum height is reached
* - mirror box-sizing
*
*/
jQuery.fn.autoGrow = function(options) {
return this.each(function() {
var settings = jQuery.extend({
extraLine: true,
}, options);
var createMirror = function(textarea) {
jQuery(textarea).after('<div class="autogrow-textarea-mirror"></div>');
return jQuery(textarea).next('.autogrow-textarea-mirror')[0];
}
var sendContentToMirror = function (textarea) {
mirror.innerHTML = String(textarea.value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/ /g, '&nbsp;')
.replace(/\n/g, '<br />') +
(settings.extraLine? '.<br/>.' : '')
;
if (jQuery(textarea).height() != jQuery(mirror).height()) {
jQuery(textarea).height(jQuery(mirror).height());
var overflow = jQuery(mirror).height() > maxHeight ? '' : 'hidden';
jQuery(textarea).css('overflow', overflow);
}
}
var growTextarea = function () {
sendContentToMirror(this);
}
// Create a mirror
var mirror = createMirror(this);
// Store max-height
var maxHeight = parseInt(jQuery(this).css('max-height'), 10);
// Style the mirror
mirror.style.display = 'none';
mirror.style.wordWrap = 'break-word';
mirror.style.whiteSpace = 'normal';
mirror.style.padding = jQuery(this).css('paddingTop') + ' ' +
jQuery(this).css('paddingRight') + ' ' +
jQuery(this).css('paddingBottom') + ' ' +
jQuery(this).css('paddingLeft');
mirror.style.width = jQuery(this).css('width');
mirror.style.fontFamily = jQuery(this).css('font-family');
mirror.style.fontSize = jQuery(this).css('font-size');
mirror.style.lineHeight = jQuery(this).css('line-height');
mirror.style.letterSpacing = jQuery(this).css('letter-spacing');
mirror.style.boxSizing = jQuery(this).css('boxSizing');
// Style the textarea
this.style.overflow = "hidden";
this.style.minHeight = this.rows+"em";
// Bind the textarea's event
this.onkeyup = growTextarea;
this.onfocus = growTextarea;
// Fire the event for text already present
sendContentToMirror(this);
});
};

View file

@ -21,7 +21,7 @@
</div>
<div class="zammad-chat-body"></div>
<form class="zammad-chat-controls">
<textarea class="zammad-chat-input" rows="1" placeholder="<%- @T('Compose your message...') %>"></textarea>
<div class="zammad-chat-input" rows="1" placeholder="<%- @T('Compose your message...') %>" contenteditable="true"></div>
<button type="submit" class="zammad-chat-button zammad-chat-send"<%= " style='background: #{ @background }'" if @background %>><%- @T('Send') %></button>
</form>
</div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="29px" height="24px" viewBox="0 0 29 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 45.2 (43514) - http://www.bohemiancoding.com/sketch -->
<title>office365-button</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="office365-button" fill-rule="nonzero" fill="#FFFFFF">
<polyline id="office365" points="23 20.4918622 22.9835112 20.4918622 22.9835112 3.5578176 16.9485936 1.80999994 6.03297769 5.8992337 6 5.91572254 6 18.1504461 9.72647915 16.6994277 9.72647915 6.74016483 16.9485936 5.00883602 16.9156159 19.6509311 6 18.1504461 16.9156159 22.1902133 22.9835112 20.5083511 22.9835112 20.4918622"></polyline>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View file

@ -126,7 +126,23 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest
assert_response(422)
result = JSON.parse(@response.body)
assert(result['error'])
assert_equal('User already exists!', result['error'])
assert_equal('Email address is already used for other user.', result['error'])
# email missing with enabled feature
params = { firstname: 'some firstname', signup: true }
post '/api/v1/users', params.to_json, headers
assert_response(422)
result = JSON.parse(@response.body)
assert(result['error'])
assert_equal('Attribute \'email\' required!', result['error'])
# email missing with enabled feature
params = { firstname: 'some firstname', signup: true }
post '/api/v1/users', params.to_json, headers
assert_response(422)
result = JSON.parse(@response.body)
assert(result['error'])
assert_equal('Attribute \'email\' required!', result['error'])
# create user with enabled feature (take customer role)
params = { firstname: 'Me First', lastname: 'Me Last', email: 'new_here@example.com', signup: true }
@ -322,7 +338,7 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest
assert_response(422)
result = JSON.parse(@response.body)
assert(result)
assert_equal('User already exists!', result['error'])
assert_equal('Email address is already used for other user.', result['error'])
# missing required attributes
params = { note: 'some note' }
@ -330,15 +346,9 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest
assert_response(422)
result = JSON.parse(@response.body)
assert(result)
assert_equal('Attribute \'login\' required!', result['error'])
params = { firstname: 'newfirstname123', note: 'some note' }
post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials)
assert_response(422)
result = JSON.parse(@response.body)
assert(result)
assert_equal('Attribute \'login\' required!', result['error'])
assert_equal('Minimum one identifier (login, firstname, lastname, phone or email) for user is required.', result['error'])
# invalid email
params = { firstname: 'newfirstname123', email: 'some_what', note: 'some note' }
post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials)
assert_response(422)
@ -346,6 +356,20 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest
assert(result)
assert_equal('Invalid email', result['error'])
# with valid attributes
params = { firstname: 'newfirstname123', note: 'some note' }
post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials)
assert_response(201)
result = JSON.parse(@response.body)
assert(result)
user = User.find(result['id'])
assert_not(user.role?('Admin'))
assert_not(user.role?('Agent'))
assert(user.role?('Customer'))
assert(result['login'].start_with?('auto-'))
assert_equal('', result['email'])
assert_equal('newfirstname123', result['firstname'])
assert_equal('', result['lastname'])
end
test 'user index and create with agent' do

View file

@ -4,16 +4,16 @@ require 'test_helper'
class EmailDeliverTest < ActiveSupport::TestCase
test 'basic check' do
if !ENV['MAIL_SERVER']
if ENV['MAIL_SERVER'].blank?
raise "Need MAIL_SERVER as ENV variable like export MAIL_SERVER='mx.example.com'"
end
if !ENV['MAIL_SERVER_ACCOUNT']
if ENV['MAIL_SERVER_ACCOUNT'].blank?
raise "Need MAIL_SERVER_ACCOUNT as ENV variable like export MAIL_SERVER_ACCOUNT='user:somepass'"
end
server_login = ENV['MAIL_SERVER_ACCOUNT'].split(':')[0]
server_password = ENV['MAIL_SERVER_ACCOUNT'].split(':')[1]
email_address = EmailAddress.create(
email_address = EmailAddress.create!(
realname: 'me Helpdesk',
email: "me#{rand(999_999_999)}@example.com",
updated_by_id: 1,
@ -27,7 +27,7 @@ class EmailDeliverTest < ActiveSupport::TestCase
created_by_id: 1,
)
channel = Channel.create(
channel = Channel.create!(
area: 'Email::Account',
group_id: group.id,
options: {
@ -50,9 +50,9 @@ class EmailDeliverTest < ActiveSupport::TestCase
)
email_address.channel_id = channel.id
email_address.save
email_address.save!
ticket1 = Ticket.create(
ticket1 = Ticket.create!(
title: 'some delivery test',
group: group,
customer_id: 2,
@ -63,7 +63,7 @@ class EmailDeliverTest < ActiveSupport::TestCase
)
assert(ticket1, 'ticket created')
article1 = Ticket::Article.create(
article1 = Ticket::Article.create!(
ticket_id: ticket1.id,
to: 'some_recipient@example_not_existing_what_ever.com',
subject: 'some subject',
@ -189,7 +189,7 @@ class EmailDeliverTest < ActiveSupport::TestCase
# remove background jobs
Delayed::Job.destroy_all
article2 = Ticket::Article.create(
article2 = Ticket::Article.create!(
ticket_id: ticket1.id,
to: 'some_recipient@example_not_existing_what_ever.com',
subject: 'some subject2',

View file

@ -0,0 +1,232 @@
# encoding: utf-8
require 'test_helper'
require 'net/imap'
class EmailKeepOnServerTest < ActiveSupport::TestCase
setup do
if ENV['KEEP_ON_MAIL_SERVER'].blank?
raise "Need KEEP_ON_MAIL_SERVER as ENV variable like export KEEP_ON_MAIL_SERVER='mx.example.com'"
end
if ENV['KEEP_ON_MAIL_SERVER_ACCOUNT'].blank?
raise "Need KEEP_ON_MAIL_SERVER_ACCOUNT as ENV variable like export KEEP_ON_MAIL_SERVER_ACCOUNT='user:somepass'"
end
@server_login = ENV['KEEP_ON_MAIL_SERVER_ACCOUNT'].split(':')[0]
@server_password = ENV['KEEP_ON_MAIL_SERVER_ACCOUNT'].split(':')[1]
@folder = "keep_on_mail_server_#{rand(999_999_999)}"
email_address = EmailAddress.create!(
realname: 'me Helpdesk',
email: "me#{rand(999_999_999)}@example.com",
updated_by_id: 1,
created_by_id: 1,
)
group = Group.create_or_update(
name: 'KeepOnServerTest',
email_address_id: email_address.id,
updated_by_id: 1,
created_by_id: 1,
)
@channel = Channel.create!(
area: 'Email::Account',
group_id: group.id,
options: {
inbound: {
adapter: 'imap',
options: {
host: ENV['KEEP_ON_MAIL_SERVER'],
user: @server_login,
password: @server_password,
ssl: true,
folder: @folder,
#keep_on_server: true,
}
},
outbound: {
adapter: 'sendmail'
}
},
active: true,
updated_by_id: 1,
created_by_id: 1,
)
email_address.channel_id = @channel.id
email_address.save!
end
test 'keep on server' do
@channel.options[:inbound][:options][:keep_on_server] = true
@channel.save!
# clean mailbox
imap = Net::IMAP.new(ENV['KEEP_ON_MAIL_SERVER'], 993, true, nil, false)
imap.login(@server_login, @server_password)
imap.create(@folder)
imap.select(@folder)
# put unseen message in it
imap.append(@folder, "Subject: hello1
From: shugo@example.com
To: shugo@example.com
Message-ID: <some1@example_keep_on_server>
hello world
".gsub(/\n/, "\r\n"), [], Time.zone.now)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
message_meta = imap.fetch(1, ['FLAGS'])[0].attr
assert_not(message_meta['FLAGS'].include?(:Seen))
# fetch messages
article_count = Ticket::Article.count
@channel.fetch(true)
assert_equal(article_count + 1, Ticket::Article.count)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
message_meta = imap.fetch(1, ['RFC822.HEADER', 'FLAGS'])[0].attr
assert(message_meta['FLAGS'].include?(:Seen))
# fetch messages
article_count = Ticket::Article.count
@channel.fetch(true)
assert_equal(article_count, Ticket::Article.count)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
# put unseen message in it
imap.append(@folder, "Subject: hello2
From: shugo@example.com
To: shugo@example.com
Message-ID: <some2@example_keep_on_server>
hello world
".gsub(/\n/, "\r\n"), [], Time.zone.now)
message_meta = imap.fetch(1, ['FLAGS'])[0].attr
assert(message_meta['FLAGS'].include?(:Seen))
message_meta = imap.fetch(2, ['FLAGS'])[0].attr
assert_not(message_meta['FLAGS'].include?(:Seen))
# fetch messages
article_count = Ticket::Article.count
@channel.fetch(true)
assert_equal(article_count + 1, Ticket::Article.count)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(2, message_ids.count)
message_meta = imap.fetch(1, ['FLAGS'])[0].attr
assert(message_meta['FLAGS'].include?(:Seen))
message_meta = imap.fetch(2, ['FLAGS'])[0].attr
assert(message_meta['FLAGS'].include?(:Seen))
# set messages to not seen
imap.store(1, '-FLAGS', [:Seen])
imap.store(2, '-FLAGS', [:Seen])
# fetch messages
article_count = Ticket::Article.count
@channel.fetch(true)
assert_equal(article_count, Ticket::Article.count)
imap.delete(@folder)
@channel.destroy!
end
test 'keep not on server' do
@channel.options[:inbound][:options][:keep_on_server] = false
@channel.save!
# clean mailbox
imap = Net::IMAP.new(ENV['KEEP_ON_MAIL_SERVER'], 993, true, nil, false)
imap.login(@server_login, @server_password)
imap.create(@folder)
imap.select(@folder)
# put unseen message in it
imap.append(@folder, "Subject: hello1
From: shugo@example.com
To: shugo@example.com
Message-ID: <some1@example_remove_from_server>
hello world
".gsub(/\n/, "\r\n"), [], Time.zone.now)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
message_meta = imap.fetch(1, ['FLAGS'])[0].attr
assert_not(message_meta['FLAGS'].include?(:Seen))
# fetch messages
article_count = Ticket::Article.count
@channel.fetch(true)
assert_equal(article_count + 1, Ticket::Article.count)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
# put unseen message in it
imap.append(@folder, "Subject: hello2
From: shugo@example.com
To: shugo@example.com
Message-ID: <some2@example_remove_from_server>
hello world
".gsub(/\n/, "\r\n"), [], Time.zone.now)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
message_meta = imap.fetch(1, ['FLAGS'])[0].attr
assert_not(message_meta['FLAGS'].include?(:Seen))
# fetch messages
article_count = Ticket::Article.count
@channel.fetch(true)
assert_equal(article_count + 1, Ticket::Article.count)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
# put unseen message in it
imap.append(@folder, "Subject: hello2
From: shugo@example.com
To: shugo@example.com
Message-ID: <some2@example_remove_from_server>
hello world
".gsub(/\n/, "\r\n"), [], Time.zone.now)
# verify if message is still on server
message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
assert_equal(1, message_ids.count)
# fetch messages
article_count = Ticket::Article.count
@channel.fetch(true)
assert_equal(article_count + 1, Ticket::Article.count)
imap.delete(@folder)
@channel.destroy!
end
end

View file

@ -51,7 +51,7 @@ class GeoLocationTest < ActiveSupport::TestCase
login: 'some_geo_login2',
firstname: 'First',
lastname: 'Last',
email: 'some_geo_login1@example.com',
email: 'some_geo_login2@example.com',
password: 'test',
street: 'Marienstrasse 13',
city: 'Berlin',

View file

@ -10,7 +10,7 @@ class ActivityStreamTest < ActiveSupport::TestCase
login: 'admin',
firstname: 'Bob',
lastname: 'Smith',
email: 'bob@example.com',
email: 'bob+active_stream@example.com',
password: 'some_pass',
active: true,
roles: roles,
@ -23,7 +23,7 @@ class ActivityStreamTest < ActiveSupport::TestCase
end
test 'ticket+user' do
ticket = Ticket.create(
ticket = Ticket.create!(
group_id: Group.lookup(name: 'Users').id,
customer_id: @current_user.id,
owner_id: User.lookup(login: '-').id,
@ -35,7 +35,7 @@ class ActivityStreamTest < ActiveSupport::TestCase
)
travel 2.seconds
article = Ticket::Article.create(
article = Ticket::Article.create!(
ticket_id: ticket.id,
updated_by_id: @current_user.id,
created_by_id: @current_user.id,
@ -86,12 +86,12 @@ class ActivityStreamTest < ActiveSupport::TestCase
assert(stream.empty?)
# cleanup
ticket.destroy
ticket.destroy!
travel_back
end
test 'organization' do
organization = Organization.create(
organization = Organization.create!(
name: 'some name',
updated_by_id: @current_user.id,
created_by_id: @current_user.id,
@ -125,12 +125,12 @@ class ActivityStreamTest < ActiveSupport::TestCase
assert(stream.empty?)
# cleanup
organization.destroy
organization.destroy!
travel_back
end
test 'user with update check false' do
user = User.create(
user = User.create!(
login: 'someemail@example.com',
email: 'someemail@example.com',
firstname: 'Bob Smith II',
@ -157,12 +157,12 @@ class ActivityStreamTest < ActiveSupport::TestCase
assert(stream.empty?)
# cleanup
user.destroy
user.destroy!
travel_back
end
test 'user with update check true' do
user = User.create(
user = User.create!(
login: 'someemail@example.com',
email: 'someemail@example.com',
firstname: 'Bob Smith II',
@ -204,7 +204,7 @@ class ActivityStreamTest < ActiveSupport::TestCase
assert(stream.empty?)
# cleanup
user.destroy
user.destroy!
travel_back
end

View file

@ -22,6 +22,12 @@ class EmailBuildTest < ActiveSupport::TestCase
assert(result !~ /font-family/, 'test 2')
assert(result =~ %r{<b>test</b>}, 'test 2')
# Issue #1230, missing backslashes
# 'Test URL: \\storage\project\100242-Inc'
html = '<b>Test URL</b>: \\\\storage\\project\\100242-Inc'
result = Channel::EmailBuild.html_complete_check(html)
assert(result.include?(html), 'backslashes must be kept')
end
test 'html email + attachment check' do

View file

@ -155,12 +155,12 @@ class HistoryTest < ActiveSupport::TestCase
# use transaction
ActiveRecord::Base.transaction do
ticket = Ticket.create(test[:ticket_create][:ticket])
ticket = Ticket.create!(test[:ticket_create][:ticket])
test[:ticket_create][:article][:ticket_id] = ticket.id
article = Ticket::Article.create(test[:ticket_create][:article])
article = Ticket::Article.create!(test[:ticket_create][:article])
assert_equal(ticket.class.to_s, 'Ticket')
assert_equal(article.class.to_s, 'Ticket::Article')
assert_equal(ticket.class, Ticket)
assert_equal(article.class, Ticket::Article)
# update ticket
if test[:ticket_update][:ticket]
@ -185,25 +185,21 @@ class HistoryTest < ActiveSupport::TestCase
}
# delete tickets
tickets.each { |ticket|
ticket_id = ticket.id
ticket.destroy
found = Ticket.where(id: ticket_id).first
assert_not(found, 'Ticket destroyed')
}
tickets.each(&:destroy!)
end
test 'user' do
name = rand(999_999)
tests = [
# test 1
{
user_create: {
user: {
login: 'some_login_test',
login: "some_login_test-#{name}",
firstname: 'Bob',
lastname: 'Smith',
email: 'somebody@example.com',
email: "somebody-#{name}@example.com",
active: true,
updated_by_id: current_user.id,
created_by_id: current_user.id,
@ -213,7 +209,7 @@ class HistoryTest < ActiveSupport::TestCase
user: {
firstname: 'Bob',
lastname: 'Master',
email: 'master@example.com',
email: "master-#{name}@example.com",
active: false,
},
},
@ -236,8 +232,8 @@ class HistoryTest < ActiveSupport::TestCase
history_object: 'User',
history_type: 'updated',
history_attribute: 'email',
value_from: 'somebody@example.com',
value_to: 'master@example.com',
value_from: "somebody-#{name}@example.com",
value_to: "master-#{name}@example.com",
},
{
result: true,
@ -258,9 +254,8 @@ class HistoryTest < ActiveSupport::TestCase
# user transaction
ActiveRecord::Base.transaction do
user = User.create(test[:user_create][:user])
assert_equal(user.class.to_s, 'User')
user = User.create!(test[:user_create][:user])
assert_equal(user.class, User)
# update user
if test[:user_update][:user]
@ -277,12 +272,7 @@ class HistoryTest < ActiveSupport::TestCase
}
# delete user
users.each { |user|
user_id = user.id
user.destroy
found = User.where(id: user_id).first
assert_not(found, 'User destroyed')
}
users.each(&:destroy!)
end
test 'organization' do
@ -328,9 +318,8 @@ class HistoryTest < ActiveSupport::TestCase
# user transaction
ActiveRecord::Base.transaction do
organization = Organization.create(test[:organization_create][:organization])
assert_equal(organization.class.to_s, 'Organization')
organization = Organization.create!(test[:organization_create][:organization])
assert_equal(organization.class, Organization)
# update organization
if test[:organization_update][:organization]
@ -346,12 +335,7 @@ class HistoryTest < ActiveSupport::TestCase
}
# delete user
organizations.each { |organization|
organization_id = organization.id
organization.destroy
found = Organization.where(id: organization_id).first
assert_not(found, 'Organization destroyed')
}
organizations.each(&:destroy!)
end
def history_check(history_list, history_check)

215
test/unit/overview_test.rb Normal file
View file

@ -0,0 +1,215 @@
# encoding: utf-8
require 'test_helper'
class OverviewTest < ActiveSupport::TestCase
test 'overview link' do
UserInfo.current_user_id = 1
overview = Overview.create!(
name: 'Not Shown Admin 2',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_equal(overview.link, 'not_shown_admin_2')
overview.destroy!
overview = Overview.create!(
name: 'My assigned Tickets',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_equal(overview.link, 'my_assigned_tickets')
overview.destroy!
overview = Overview.create!(
name: 'Übersicht',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_equal(overview.link, 'ubersicht')
overview.destroy!
overview = Overview.create!(
name: " Übersicht \n",
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_equal(overview.link, 'ubersicht')
overview.destroy!
overview1 = Overview.create!(
name: 'Meine Übersicht',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_equal(overview1.link, 'meine_ubersicht')
overview2 = Overview.create!(
name: 'Meine Übersicht',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert(overview2.link.start_with?('meine_ubersicht'))
assert_not_equal(overview1.link, overview2.link)
overview1.destroy!
overview2.destroy!
overview = Overview.create!(
name: 'Д дФ ф',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_match(/^\d{1,3}$/, overview.link)
overview.destroy!
overview = Overview.create!(
name: ' Д дФ ф abc ',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_equal(overview.link, 'abc')
overview.destroy!
overview = Overview.create!(
name: 'Übersicht',
link: 'my_overview',
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
assert_equal(overview.link, 'my_overview')
overview.name = 'Übersicht2'
overview.link = 'my_overview2'
overview.save!
assert_equal(overview.link, 'my_overview2')
overview.destroy!
end
end

View file

@ -0,0 +1,34 @@
# encoding: utf-8
require 'test_helper'
class TicketNullByteTest < ActiveSupport::TestCase
test 'null byte test' do
ticket1 = Ticket.create!(
title: "some title \u0000 123",
group: Group.lookup(name: 'Users'),
customer_id: 2,
updated_by_id: 1,
created_by_id: 1,
)
assert(ticket1, 'ticket created')
article1 = Ticket::Article.create!(
ticket_id: ticket1.id,
from: 'some_customer_com-1@example.com',
to: 'some_zammad_com-1@example.com',
subject: "com test 1\u0000",
message_id: 'some@id_com_1',
body: "some\u0000message 123",
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,
)
assert(article1, 'ticket created')
ticket1.destroy!
article1.destroy!
end
end

View file

@ -302,7 +302,7 @@ class TicketOverviewTest < ActiveSupport::TestCase
)
end
test 'bbb overiview index' do
test 'bbb overview index' do
result = Ticket::Overviews.all(
current_user: @agent1,
@ -343,7 +343,7 @@ class TicketOverviewTest < ActiveSupport::TestCase
end
test 'ccc overiview content' do
test 'ccc overview content' do
Ticket.destroy_all

View file

@ -245,9 +245,9 @@ class UserTest < ActiveSupport::TestCase
tests.each { |test|
# check if user exists
user = User.where( login: test[:create][:login] ).first
user = User.where(login: test[:create][:login]).first
if user
user.destroy
user.destroy!
end
user = User.create( test[:create] )
@ -266,8 +266,8 @@ class UserTest < ActiveSupport::TestCase
end
}
if test[:create_verify][:image_md5]
file = Avatar.get_by_hash( user.image )
file_md5 = Digest::MD5.hexdigest( file.content )
file = Avatar.get_by_hash(user.image)
file_md5 = Digest::MD5.hexdigest(file.content)
assert_equal(test[:create_verify][:image_md5], file_md5, "create avatar md5 check in (#{test[:name]})")
end
if test[:update]
@ -275,7 +275,7 @@ class UserTest < ActiveSupport::TestCase
test[:update_verify].each { |key, value|
next if key == :image_md5
if user.respond_to?( key )
if user.respond_to?(key)
assert_equal(value, user.send(key), "update check #{key} in (#{test[:name]})")
else
assert_equal(value, user[key], "update check #{key} in (#{test[:name]})")
@ -289,10 +289,252 @@ class UserTest < ActiveSupport::TestCase
end
end
user.destroy
user.destroy!
}
end
test 'without email - but login eq email' do
name = rand(999_999_999)
login = "admin-role_without_email#{name}@example.com"
email = "admin-role_without_email#{name}@example.com"
admin = User.create_or_update(
login: login,
firstname: 'Role',
lastname: "Admin#{name}",
#email: "",
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
assert(admin.id)
assert_equal(admin.login, login)
assert_equal(admin.email, '')
admin.email = email
admin.save!
assert_equal(admin.login, login)
assert_equal(admin.email, email)
admin.email = ''
admin.save!
assert(admin.id)
assert(admin.login)
assert_not_equal(admin.login, login)
assert_equal(admin.email, '')
admin.destroy!
end
test 'without email - but login ne email' do
name = rand(999_999_999)
login = "admin-role_without_email#{name}"
email = "admin-role_without_email#{name}@example.com"
admin = User.create_or_update(
login: login,
firstname: 'Role',
lastname: "Admin#{name}",
#email: "",
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
assert(admin.id)
assert_equal(admin.login, login)
assert_equal(admin.email, '')
admin.email = email
admin.save!
assert_equal(admin.login, login)
assert_equal(admin.email, email)
admin.email = ''
admin.save!
assert(admin.id)
assert_equal(admin.login, login)
assert_equal(admin.email, '')
admin.destroy!
end
test 'uniq email' do
name = rand(999_999_999)
email1 = "admin1-role_without_email#{name}@example.com"
admin1 = User.create!(
login: email1,
firstname: 'Role',
lastname: "Admin1#{name}",
email: email1,
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
assert(admin1.id)
assert_equal(admin1.email, email1)
assert_raises(Exceptions::UnprocessableEntity) {
User.create!(
login: "#{email1}-1",
firstname: 'Role',
lastname: "Admin1#{name}",
email: email1,
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
}
email2 = "admin2-role_without_email#{name}@example.com"
admin2 = User.create!(
firstname: 'Role',
lastname: "Admin2#{name}",
email: email2,
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
assert_raises(Exceptions::UnprocessableEntity) {
admin2.email = email1
admin2.save!
}
admin1.email = admin1.email
admin1.save!
admin2.destroy!
admin1.destroy!
end
test 'uniq email - multiple use' do
Setting.set('user_email_multiple_use', true)
name = rand(999_999_999)
email1 = "admin1-role_without_email#{name}@example.com"
admin1 = User.create!(
login: email1,
firstname: 'Role',
lastname: "Admin1#{name}",
email: email1,
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
assert(admin1.id)
assert_equal(admin1.email, email1)
admin2 = User.create!(
login: "#{email1}-1",
firstname: 'Role',
lastname: "Admin1#{name}",
email: email1,
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
assert_equal(admin2.email, email1)
admin2.destroy!
admin1.destroy!
Setting.set('user_email_multiple_use', false)
end
test 'ensure roles' do
name = rand(999_999_999)
admin = User.create_or_update(
login: "admin-role#{name}@example.com",
firstname: 'Role',
lastname: "Admin#{name}",
email: "admin-role#{name}@example.com",
password: 'adminpw',
active: true,
roles: Role.where(name: %w(Admin Agent)),
updated_by_id: 1,
created_by_id: 1,
)
customer1 = User.create_or_update(
login: "user-ensure-role1-#{name}@example.com",
firstname: 'Role',
lastname: "Customer#{name}",
email: "user-ensure-role1-#{name}@example.com",
password: 'customerpw',
active: true,
updated_by_id: 1,
created_by_id: 1,
)
assert_equal(customer1.role_ids.sort, Role.signup_role_ids)
roles = Role.where(name: 'Agent')
customer1.roles = roles
customer1.save!
assert_equal(customer1.role_ids.count, 1)
assert_equal(customer1.role_ids.first, roles.first.id)
assert_equal(customer1.roles.first.id, roles.first.id)
customer1.roles = []
customer1.save!
assert_equal(customer1.role_ids.sort, Role.signup_role_ids)
customer1.destroy!
customer2 = User.create_or_update(
login: "user-ensure-role2-#{name}@example.com",
firstname: 'Role',
lastname: "Customer#{name}",
email: "user-ensure-role2-#{name}@example.com",
password: 'customerpw',
roles: roles,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
assert_equal(customer2.role_ids.count, 1)
assert_equal(customer2.role_ids.first, roles.first.id)
assert_equal(customer2.roles.first.id, roles.first.id)
roles = Role.where(name: 'Admin')
customer2.role_ids = [roles.first.id]
customer2.save!
assert_equal(customer2.role_ids.count, 1)
assert_equal(customer2.role_ids.first, roles.first.id)
assert_equal(customer2.roles.first.id, roles.first.id)
customer2.roles = []
customer2.save!
assert_equal(customer2.role_ids.sort, Role.signup_role_ids)
customer2.destroy!
admin.destroy!
end
test 'user default preferences' do
name = rand(999_999_999)
groups = Group.where(name: 'Users')
@ -352,7 +594,6 @@ class UserTest < ActiveSupport::TestCase
assert(customer1.preferences['notification_config'])
assert(customer1.preferences['notification_config']['matrix']['create'])
assert(customer1.preferences['notification_config']['matrix']['update'])
end
test 'permission' do
@ -557,7 +798,7 @@ class UserTest < ActiveSupport::TestCase
# So we need to merge them with the User Nr 1 and destroy them afterwards
User.with_permissions('admin').each do |user|
Models.merge('User', 1, user.id)
user.destroy
user.destroy!
end
# store current admin count

View file

@ -0,0 +1,13 @@
class MicrosoftOffice365Database < OmniAuth::Strategies::MicrosoftOffice365
option :name, 'microsoft_office365'
def initialize(app, *args, &block)
# database lookup
config = Setting.get('auth_microsoft_office365_credentials') || {}
args[0] = config['app_id']
args[1] = config['app_secret']
super
end
end