Browse Source

- Added Exchange integration.
- Added Sequencer.
- Prepared migration of LDAP integration to Sequencer.
- Added and improved RSpec support helpers.

Thorsten Eckel 7 years ago
parent
commit
4937d742ea

+ 3 - 0
Gemfile

@@ -80,6 +80,9 @@ gem 'browser'
 gem 'slack-notifier'
 gem 'clearbit'
 gem 'zendesk_api'
+gem 'viewpoint'
+gem 'rubyntlm', git: 'https://github.com/wimm/rubyntlm.git'
+gem 'autodiscover', git: 'https://github.com/thorsteneckel/autodiscover.git'
 
 # event machine
 gem 'eventmachine'

+ 30 - 0
Gemfile.lock

@@ -1,3 +1,19 @@
+GIT
+  remote: https://github.com/thorsteneckel/autodiscover.git
+  revision: 29d713ee0c8c25fcf74c4292ff13fe1fa4d0d827
+  specs:
+    autodiscover (1.0.2)
+      httpclient
+      logging
+      nokogiri
+      nori
+
+GIT
+  remote: https://github.com/wimm/rubyntlm.git
+  revision: 53969639b87b9e5d5fef560f19cf0d977259591c
+  specs:
+    rubyntlm (0.1.2)
+
 GEM
   remote: https://rubygems.org/
   specs:
@@ -166,6 +182,7 @@ GEM
       domain_name (~> 0.5)
     http-form_data (1.0.3)
     http_parser.rb (0.6.0)
+    httpclient (2.8.3)
     i18n (0.8.6)
     icalendar (2.4.1)
     inflection (1.0.0)
@@ -181,6 +198,10 @@ GEM
       rb-fsevent (~> 0.9, >= 0.9.4)
       rb-inotify (~> 0.9, >= 0.9.7)
       ruby_dep (~> 1.2)
+    little-plugger (1.1.4)
+    logging (2.2.2)
+      little-plugger (~> 1.1)
+      multi_json (~> 1.10)
     loofah (2.0.3)
       nokogiri (>= 1.5.9)
     lumberjack (1.0.10)
@@ -205,6 +226,7 @@ GEM
     netrc (0.11.0)
     nokogiri (1.8.0)
       mini_portile2 (~> 2.2.0)
+    nori (2.6.0)
     notiffany (0.1.1)
       nenv (~> 0.1)
       shellany (~> 0.0)
@@ -408,6 +430,11 @@ GEM
     valid_email2 (2.0.0)
       activemodel (>= 3.2)
       mail (~> 2.5)
+    viewpoint (1.1.0)
+      httpclient
+      logging
+      nokogiri
+      rubyntlm
     webmock (3.0.1)
       addressable (>= 2.3.6)
       crack (>= 0.3.2)
@@ -428,6 +455,7 @@ DEPENDENCIES
   activerecord-nulldb-adapter
   activerecord-session_store
   argon2
+  autodiscover!
   autoprefixer-rails
   biz
   browser
@@ -479,6 +507,7 @@ DEPENDENCIES
   rb-fsevent
   rspec-rails
   rubocop
+  rubyntlm!
   sass-rails
   selenium-webdriver
   simple-rss
@@ -496,6 +525,7 @@ DEPENDENCIES
   uglifier
   unicorn
   valid_email2
+  viewpoint
   webmock
   writeexcel
   zendesk_api

+ 522 - 0
app/assets/javascripts/app/controllers/_integration/exchange.coffee

@@ -0,0 +1,522 @@
+class Index extends App.ControllerIntegrationBase
+  featureIntegration: 'exchange_integration'
+  featureName: 'Exchange'
+  featureConfig: 'exchange_config'
+  description: [
+    ['This service enables Zammad to connect with your Exchange server.']
+  ]
+  events:
+    'change .js-switch input': 'switch'
+
+  render: =>
+    super
+    new Form(
+      el: @$('.js-form')
+    )
+
+    #new App.ImportJob(
+    #  el: @$('.js-importJob')
+    #  facility: 'exchange'
+    #)
+
+    new App.HttpLog(
+      el: @$('.js-log')
+      facility: 'exchange'
+    )
+
+  switch: =>
+    super
+    active = @$('.js-switch input').prop('checked')
+    if active
+      job_start = =>
+        @ajax(
+          id:   'jobs_config'
+          type: 'POST'
+          url:  "#{@apiPath}/integration/exchange/job_start"
+          processData: true
+          success: (data, status, xhr) =>
+            @render(true)
+        )
+
+      App.Delay.set(
+        job_start,
+        600,
+        'job_start',
+      )
+
+class Form extends App.Controller
+  elements:
+    '.js-lastImport': 'lastImport'
+    '.js-wizard': 'wizardButton'
+  events:
+    'click .js-wizard': 'startWizard'
+    'click .js-start-sync': 'startSync'
+
+  constructor: ->
+    super
+    @render()
+    @lastResult()
+    @activeDryRun()
+
+  currentConfig: ->
+    App.Setting.get('exchange_config') || {}
+
+  setConfig: (value) =>
+    App.Setting.set('exchange_config', value, {notify: true})
+    @startSync()
+
+  render: (top = false) =>
+    @config = @currentConfig()
+
+    folders = []
+    if !_.isEmpty(@config.folders)
+      for folder_id in @config.folders
+        folders.push @config.wizardData.backend_folders[folder_id]
+
+    @html App.view('integration/exchange')(
+      config: @config,
+      folders: folders
+    )
+    if _.isEmpty(@config)
+      @$('.js-notConfigured').removeClass('hide')
+      @$('.js-summary').addClass('hide')
+    else
+      @$('.js-notConfigured').addClass('hide')
+      @$('.js-summary').removeClass('hide')
+
+    if top
+      a = =>
+        @scrollToIfNeeded($('.content.active .page-header'))
+      @delay(a, 500)
+
+  startSync: =>
+    @ajax(
+      id:   'jobs_config'
+      type: 'POST'
+      url:  "#{@apiPath}/integration/exchange/job_start"
+      processData: true
+      success: (data, status, xhr) =>
+        @render(true)
+        @lastResult()
+    )
+
+  startWizard: (e) =>
+    e.preventDefault()
+    new ConnectionWizard(
+      container: @el.closest('.content')
+      config: @config
+      callback: (config) =>
+        @setConfig(config)
+    )
+
+  lastResult: =>
+    @ajax(
+      id:   'jobs_start_index'
+      type: 'GET'
+      url:  "#{@apiPath}/integration/exchange/job_start"
+      processData: true
+      success: (job, status, xhr) =>
+        if !_.isEmpty(job)
+          if !@lastResultShowJob || @lastResultShowJob.updated_at != job.updated_at
+            @lastResultShowJob = job
+            @lastResultShow(job)
+            if job.finished_at
+              @wizardButton.attr('disabled', false)
+            else
+              @wizardButton.attr('disabled', true)
+        @delay(@lastResult, 5000)
+    )
+
+  lastResultShow: (job) =>
+    if _.isEmpty(job)
+      @lastImport.html('')
+      return
+    countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped + job.result.failed
+    if !job.result.roles
+      job.result.roles = {}
+    for role_id, statistic of job.result.role_ids
+      role = App.Role.find(role_id)
+      job.result.roles[role.displayName()] = statistic
+    el = $(App.view('integration/exchange_last_import')(job: job, countDone: countDone))
+    @lastImport.html(el)
+
+  activeDryRun: =>
+    @ajax(
+      id:   'jobs_try_index'
+      type: 'GET'
+      url:  "#{@apiPath}/integration/exchange/job_try"
+      data:
+        finished: false
+      processData: true
+      success: (job, status, xhr) =>
+        return if _.isEmpty(job)
+
+        # show analyzing
+        new ConnectionWizard(
+          container: @el.closest('.content')
+          config: job.payload
+          start: 'tryLoop'
+          callback: (config) =>
+            @wizardButton.attr('disabled', false)
+            @setConfig(config)
+        )
+        @wizardButton.attr('disabled', true)
+    )
+
+class State
+  @current: ->
+    App.Setting.get('exchange_integration')
+
+class ConnectionWizard extends App.WizardModal
+  wizardConfig: {}
+  slideMethod:
+    'js-folders': 'foldersShow'
+    'js-mapping': 'mappingShow'
+
+  events:
+    'submit form.js-discover':           'discover'
+    'submit form.js-bind':               'folders'
+    'submit form.js-folders':            'mapping'
+    'click .js-mapping .js-submitTry':   'mappingChange'
+    'click .js-try .js-submitSave':      'save'
+    'click .js-close':                   'hide'
+    'click .js-remove':                  'removeRow'
+    'click .js-userMappingForm .js-add': 'addUserMapping'
+    'click .js-goToSlide':               'goToSlide'
+
+  elements:
+    '.modal-body': 'body'
+    '.js-foldersSelect': 'foldersSelect'
+    '.js-userMappingForm': 'userMappingForm'
+    '.js-expertForm': 'expertForm'
+
+  constructor: ->
+    super
+
+    if !_.isEmpty(@config)
+      @wizardConfig = @config
+
+    if @container
+      @el.addClass('modal--local')
+
+    @render()
+
+    @el.modal
+      keyboard:  true
+      show:      true
+      backdrop:  true
+      container: @container
+    .on
+      'show.bs.modal':   @onShow
+      'shown.bs.modal':  @onShown
+      'hidden.bs.modal': =>
+        @el.remove()
+
+    if @slide
+      @showSlide(@slide)
+    else
+      @showDiscoverDetails()
+
+    if @start
+      @[@start]()
+
+  render: =>
+    @html App.view('integration/exchange_wizard')()
+
+  save: (e) =>
+    e.preventDefault()
+    @callback(@wizardConfig)
+    @hide(e)
+
+  showSlide: (slide) =>
+    method = @slideMethod[slide]
+    if method && @[method]
+      @[method](true)
+    super
+
+  showDiscoverDetails: =>
+    @$('.js-discover input[name="user"]').val(@wizardConfig.user)
+    @$('.js-discover input[name="password"]').val(@wizardConfig.password)
+
+  showBindDetails: =>
+    @$('.js-bind input[name="endpoint"]').val(@wizardConfig.endpoint)
+    @$('.js-bind input[name="user"]').val(@wizardConfig.user)
+    @$('.js-bind input[name="password"]').val(@wizardConfig.password)
+
+  discover: (e) =>
+    e.preventDefault()
+    @showSlide('js-connect')
+    params = @formParam(e.target)
+    @ajax(
+      id:   'exchange_discover'
+      type: 'POST'
+      url:  "#{@apiPath}/integration/exchange/autodiscover"
+      data: JSON.stringify(params)
+      processData: true
+      success: (data, status, xhr) =>
+        if data.result isnt 'ok'
+          @showSlide('js-discover')
+          @showAlert('js-discover', data.message)
+          return
+
+        @wizardConfig.endpoint = data.endpoint
+        @wizardConfig.user     = params.user
+        @wizardConfig.password = params.password
+
+        @showSlide('js-bind')
+        @showBindDetails()
+
+      error: (xhr, statusText, error) =>
+        detailsRaw = xhr.responseText
+        details = {}
+        if !_.isEmpty(detailsRaw)
+          details = JSON.parse(detailsRaw)
+        @showSlide('js-discover')
+        @showAlert('js-discover', details.error || 'Unable to perform backend.')
+    )
+
+  folders: (e) =>
+    e.preventDefault()
+    @showSlide('js-analyze')
+    params = @formParam(e.target)
+    @ajax(
+      id:   'exchange_folders'
+      type: 'POST'
+      url:  "#{@apiPath}/integration/exchange/folders"
+      data: JSON.stringify(params)
+      processData: true
+      success: (data, status, xhr) =>
+        if data.result isnt 'ok'
+          @showSlide('js-bind')
+          @showAlert('js-bind', data.message)
+          return
+
+        @wizardConfig.endpoint = params.endpoint
+        @wizardConfig.user     = params.user
+        @wizardConfig.password = params.password
+
+        # update wizard data
+        @wizardConfig.wizardData = {}
+        @wizardConfig.wizardData.backend_folders = data.folders
+
+        @foldersShow()
+
+      error: (xhr, statusText, error) =>
+        detailsRaw = xhr.responseText
+        details = {}
+        if !_.isEmpty(detailsRaw)
+          details = JSON.parse(detailsRaw)
+        @showSlide('js-bind')
+        @showAlert('js-bind', details.error || 'Unable to perform backend.')
+    )
+
+  foldersShow: (alreadyShown) =>
+    @showSlide('js-folders') if !alreadyShown
+    @foldersSelect.html(@createColumnSelection('folders', @wizardConfig.wizardData.backend_folders, @wizardConfig.folders))
+
+  createColumnSelection: (name, options, selected) ->
+    return App.UiElement.column_select.render(
+      name: name
+      null: false
+      nulloption: false
+      options: options
+      value: selected
+    )
+
+  mapping: (e) =>
+    e.preventDefault()
+    @showSlide('js-analyze')
+    params = @formParam(e.target)
+
+    # folders might be a single selection so we
+    # have to ensure that is an Array so the
+    # backend and frontend can handle it properly
+    if typeof params.folders is 'string'
+      params.folders = [ params.folders ]
+
+    # add login params
+    params.endpoint = @wizardConfig.endpoint
+    params.user     = @wizardConfig.user
+    params.password = @wizardConfig.password
+
+    @ajax(
+      id:   'exchange_mapping'
+      type: 'POST'
+      url:  "#{@apiPath}/integration/exchange/mapping"
+      data: JSON.stringify(params)
+      processData: true
+      success: (data, status, xhr) =>
+        if data.result isnt 'ok'
+          @showSlide('js-folders')
+          @showAlert('js-folders', data.message)
+          return
+
+        attributes = {}
+        for key, value of App.User.attributesGet()
+          continue if key == 'login'
+          if (value.tag is 'input' || value.tag is 'richtext' || value.tag is 'textarea')  && value.type isnt 'password'
+            attributes[key] = value.display || key
+
+        @wizardConfig.wizardData.attributes         = attributes
+        @wizardConfig.folders                       = params.folders
+        @wizardConfig.wizardData.backend_attributes = data.attributes
+
+        @mappingShow()
+
+      error: (xhr, statusText, error) =>
+        detailsRaw = xhr.responseText
+        details = {}
+        if !_.isEmpty(detailsRaw)
+          details = JSON.parse(detailsRaw)
+        @showSlide('js-folders')
+        @showAlert('js-folders', details.error || 'Unable to perform backend.')
+    )
+
+  mappingShow: (alreadyShown) =>
+    @showSlide('js-mapping') if !alreadyShown
+    user_attribute_map = @wizardConfig.attributes
+
+    if _.isEmpty(user_attribute_map)
+      user_attribute_map =
+        given_name: 'firstname'
+        surname: 'lastname'
+        'email_addresses.emailaddress1': 'email'
+        'phone_numbers.businessphone': 'phone'
+
+    @userMappingForm.find('tbody tr.js-entry').remove()
+    @userMappingForm.find('tbody tr').before(@buildRowsUserMap(user_attribute_map))
+
+  mappingChange: (e) =>
+    e.preventDefault()
+
+    # user map
+    attributes = @formParam(@userMappingForm)
+    for key in ['source', 'dest']
+      if !_.isArray(attributes[key])
+        attributes[key] = [attributes[key]]
+    attributes_local =
+      item_id: 'login'
+    length = attributes.source.length-1
+    for count in [0..length]
+      if attributes.source[count] && attributes.dest[count]
+        attributes_local[attributes.source[count]] = attributes.dest[count]
+    @wizardConfig.attributes = attributes_local
+
+    @tryShow()
+
+  buildRowsUserMap: (user_attribute_map) =>
+
+    # show static login row
+    userUidDisplayValue = @wizardConfig.wizardData.backend_attributes['item_id']
+    el = [
+      $(App.view('integration/ldap_user_attribute_row_read_only')(
+        key:   userUidDisplayValue,
+        value: 'Login'
+      ))
+    ]
+
+    for source, dest of user_attribute_map
+      continue if source == 'item_id'
+      continue if !(source of @wizardConfig.wizardData.backend_attributes)
+      el.push @buildRowUserAttribute(source, dest)
+    el
+
+  buildRowUserAttribute: (source, dest) =>
+    el = $(App.view('integration/exchange_user_attribute_row')())
+    el.find('.js-exchangeAttribute').html(@createSelection('source', @wizardConfig.wizardData.backend_attributes, source))
+    el.find('.js-userAttribute').html(@createSelection('dest', @wizardConfig.wizardData.attributes, dest))
+    el
+
+  createSelection: (name, options, selected, unknown) ->
+    return App.UiElement.searchable_select.render(
+      name: name
+      multiple: false
+      limit: 100
+      null: false
+      nulloption: false
+      options: options
+      value: selected
+      unknown: unknown
+      class: 'form-control--small'
+    )
+
+  removeRow: (e) ->
+    e.preventDefault()
+    $(e.target).closest('tr').remove()
+
+  addUserMapping: (e) =>
+    e.preventDefault()
+    @userMappingForm.find('tbody tr').last().before(@buildRowUserAttribute())
+
+  tryShow: (e) =>
+    if e
+      e.preventDefault()
+    @showSlide('js-analyze')
+
+    # create import job
+    @ajax(
+      id:   'exchange_try'
+      type: 'POST'
+      url:  "#{@apiPath}/integration/exchange/job_try"
+      data: JSON.stringify(@wizardConfig)
+      processData: true
+      success: (data, status, xhr) =>
+        @tryLoop()
+    )
+
+  tryLoop: =>
+    @showSlide('js-dry')
+    @ajax(
+      id:   'jobs_try_index'
+      type: 'GET'
+      url:  "#{@apiPath}/integration/exchange/job_try"
+      data:
+        finished: true
+      processData: true
+      success: (job, status, xhr) =>
+        if job.result && (job.result.error || job.result.info)
+          @showSlide('js-error')
+          @showAlert('js-error', (job.result.error || job.result.info))
+          return
+
+        total = 0
+        if job.result && _.keys(job.result).length > 0
+          @$('.js-preprogress').addClass('hide')
+          @$('.js-analyzing').removeClass('hide')
+
+          analized = 0
+          total    = job.result.sum
+          for action, sum of job.result
+            continue if action == 'folders'
+            continue if action == 'sum'
+            analized += sum
+
+          @$('.js-progress progress').attr('value', analized)
+          @$('.js-progress progress').attr('max', total)
+
+        if job.finished_at
+          # reset initial state in case the back button is used
+          @$('.js-preprogress').removeClass('hide')
+          @$('.js-analyzing').addClass('hide')
+
+          @tryResult(job, total)
+        else
+          @delay(@tryLoop, 4000)
+    )
+
+  tryResult: (job, total) =>
+    @showSlide('js-try')
+    el = $(App.view('integration/exchange_summary')(job: job, countDone: total))
+    @el.find('.js-summary').html(el)
+
+App.Config.set(
+  'IntegrationExchange'
+  {
+    name: 'Exchange'
+    target: '#system/integration/exchange'
+    description: 'Exchange integration for contacts management.'
+    controller: Index
+    state: State
+  }
+  'NavBarIntegrations'
+)

+ 15 - 7
app/assets/javascripts/app/controllers/_integration/ldap.coffee

@@ -28,13 +28,20 @@ class Index extends App.ControllerIntegrationBase
     super
     active = @$('.js-switch input').prop('checked')
     if active
-      @ajax(
-        id:   'jobs_config'
-        type: 'POST'
-        url:  "#{@apiPath}/integration/ldap/job_start"
-        processData: true
-        success: (data, status, xhr) =>
-          @render(true)
+      job_start = =>
+        @ajax(
+          id:   'jobs_config'
+          type: 'POST'
+          url:  "#{@apiPath}/integration/ldap/job_start"
+          processData: true
+          success: (data, status, xhr) =>
+            @render(true)
+        )
+
+      App.Delay.set(
+        job_start,
+        600,
+        'job_start',
       )
 
 class Form extends App.Controller
@@ -91,6 +98,7 @@ class Form extends App.Controller
       processData: true
       success: (data, status, xhr) =>
         @render(true)
+        @lastResult()
     )
 
   startWizard: (e) =>

+ 71 - 0
app/assets/javascripts/app/views/integration/exchange.jst.eco

@@ -0,0 +1,71 @@
+<div class="js-lastImport"></div>
+<div class="js-notConfigured">
+  <p><%- @T('No %s configured.', 'Exchange') %></p>
+  <button type="submit" class="btn btn--primary js-wizard"><%- @T('Configure') %></button>
+</div>
+<div class="js-summary hide">
+  <h2><%- @T('Settings') %></h2>
+  <table class="settings-list" style="width: 100%;">
+    <thead>
+      <tr>
+        <th width="30%"><%- @T('Name') %>
+        <th width="70%"><%- @T('Value') %>
+    </thead>
+    <tbody>
+      <tr>
+        <td class="settings-list-row-control"><%- @T('Endpoint') %>
+        <td class="settings-list-row-control"><%= @config.endpoint %>
+      <tbody>
+        <tr>
+          <td class="settings-list-row-control"><%- @T('User') %>
+          <td class="settings-list-row-control"><%= @config.user %>
+        <tr>
+          <td class="settings-list-row-control"><%- @T('Password') %>
+          <td class="settings-list-row-control"><%= @M(@config.password) %>
+    </tbody>
+  </table>
+
+  <h2><%- @T('Mapping') %></h2>
+
+  <h3><%- @T('Folders') %></h3>
+  <% if _.isEmpty(@folders): %>
+    <table class="settings-list settings-list--stretch settings-list--placeholder">
+      <thead><tr><th><%- @T('No Entries') %>
+    </table>
+  <% else: %>
+    <table class="settings-list" style="width: 100%;">
+      <thead>
+        <tr>
+          <th><%- @T('Folder') %>
+        <% for folder_name in @folders: %>
+          <tr>
+            <td class="settings-list-row-control"><%= folder_name %>
+        <% end %>
+      </thead>
+      <tbody>
+    </table>
+  <% end %>
+
+  <h3><%- @T('User') %></h3>
+  <% if _.isEmpty(@config.attributes): %>
+    <table class="settings-list settings-list--stretch settings-list--placeholder">
+      <thead><tr><th><%- @T('No Entries') %>
+    </table>
+  <% else: %>
+    <table class="settings-list" style="width: 100%;">
+      <thead>
+        <tr>
+          <th width="40%"><%- @T('Exchange') %>
+          <th width="60%"><%- @T('Zammad') %>
+        <% for key, value of @config.attributes: %>
+          <tr>
+            <td class="settings-list-row-control"><%= key %>
+            <td class="settings-list-row-control"><%= value %>
+        <% end %>
+      </thead>
+      <tbody>
+    </table>
+  <% end %>
+
+  <button type="submit" class="btn btn--primary js-wizard"><%- @T('Change') %></button>
+</div>

+ 54 - 0
app/assets/javascripts/app/views/integration/exchange_last_import.jst.eco

@@ -0,0 +1,54 @@
+<div class="box box--message">
+  <h2><%- @T('Last sync') %></h2>
+  <% if _.isEmpty(@job.started_at): %>
+    <% if @job.result && @job.result.error: %>
+        <div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
+    <% else if @job.result && @job.result.info: %>
+        <div class="alert alert--info" role="alert"><%- @T('Info: %s', @job.result.info) %></div>
+    <% else: %>
+      <p><%- @T('Job is waiting to get started...') %></p>
+    <% end %>
+  <% else: %>
+    <% if @job.finished_at: %>
+      <p><%- @Ttimestamp(@job.started_at) %> - <%- @Ttimestamp(@job.finished_at) %></p>
+      <% if @job.result && @job.result.error: %>
+        <div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
+      <% else if @job.result && @job.result.info: %>
+        <div class="alert alert--info" role="alert"><%- @T('Info: %s', @job.result.info) %></div>
+      <% end %>
+    <% else: %>
+      <% if @job.result && @job.result.error: %>
+        <p><%- @Ttimestamp(@job.started_at) %></p>
+        <div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
+      <% else if !@countDone: %>
+        <p><%- @Ttimestamp(@job.started_at) %> - <%- @T('Counting entries. This may take a while.') %></p>
+      <% else: %>
+        <p><%- @Ttimestamp(@job.started_at) %> - <%- @T('Running...') %></p>
+        <div class="flex">
+          <progress max="<%= @job.result.sum %>" value="<%= @countDone %>"></progress>
+        </div>
+      <% end %>
+    <% end %>
+    <% if !_.isEmpty(@job.result) && @countDone: %>
+      <ul>
+        <li><%- @T('%s user to %s user', 'Exchange', 'Zammad') %> (<%= @countDone %>/<%= @job.result.sum %>):
+          <ul>
+            <li><%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>
+          </ul>
+          </li>
+          <% if !_.isEmpty(@job.result.folders): %>
+            <li><%- @T('%s folders', 'Exchange') %>:
+              <ul>
+                <% for folder, result of @job.result.folders: %>
+                  <li><%- folder %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>
+                <% end %>
+              </ul>
+            </li>
+          <% end %>
+      </ul>
+    <% end %>
+    <% if @job.finished_at: %>
+      <button type="submit" class="btn btn--primary js-start-sync"><%- @T('Start new') %></button>
+    <% end %>
+  <% end %>
+</div>

+ 16 - 0
app/assets/javascripts/app/views/integration/exchange_summary.jst.eco

@@ -0,0 +1,16 @@
+<ul>
+  <li><%- @T('%s user to %s user', 'Exchange', 'Zammad') %> (<%= @countDone %>):
+    <ul>
+      <li><%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>
+    </ul>
+  </li>
+  <% if !_.isEmpty(@job.result.folders): %>
+    <li><%- @T('%s folders', 'Exchange') %>:
+      <ul>
+        <% for folder, result of @job.result.folders: %>
+          <li><%- folder %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>
+        <% end %>
+      </ul>
+    </li>
+  <% end %>
+</ul>

+ 7 - 0
app/assets/javascripts/app/views/integration/exchange_user_attribute_row.jst.eco

@@ -0,0 +1,7 @@
+<tr class="js-entry">
+  <td style="max-width: 240px" class="settings-list-control-cell js-exchangeAttribute">
+  <td class="settings-list-control-cell js-userAttribute">
+  <td class="settings-list-row-control">
+    <div class="btn btn--text js-remove">
+      <%- @Icon('trash') %> <%- @T('Remove') %>
+    </div>

+ 258 - 0
app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco

@@ -0,0 +1,258 @@
+<div class="modal-dialog">
+
+  <form class="modal-content setup wizard js-discover">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <div class="alert alert--danger hide" role="alert"></div>
+        <table class="settings-list" style="width: 100%;">
+          <thead>
+            <tr>
+              <th width="30%"><%- @T('Name') %>
+              <th width="70%"><%- @T('Value') %>
+          </thead>
+          <tbody>
+            <tr>
+              <td class="settings-list-row-control"><%- @T('User') %>
+              <td class="settings-list-control-cell"><input type="text" name="user" class="form-control form-control--small js-user" value="" placeholder="" autocomplete="off" required>
+            <tr>
+              <td class="settings-list-row-control"><%- @T('Password') %>
+              <td class="settings-list-control-cell"><input type="password" name="password" class="form-control form-control--small js-password" value="" placeholder="" autocomplete="new-password" required>
+          </tbody>
+        </table>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="modal-rightFooter">
+        <button class="btn btn--primary align-right js-submit"><%- @T('Connect') %></button>
+      </div>
+    </div>
+  </form>
+
+  <form class="modal-content setup wizard hide js-connect">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <p class="wizard-loadingText">
+          <span class="loading icon"></span> <%- @T('Connecting ...') %>
+        </p>
+      </div>
+    </div>
+    <div class="modal-footer"></div>
+  </form>
+
+  <form class="modal-content setup wizard hide js-bind">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <div class="alert alert--danger hide" role="alert"></div>
+        <table class="settings-list" style="width: 100%;">
+          <thead>
+            <tr>
+              <th width="30%"><%- @T('Name') %>
+              <th width="70%"><%- @T('Value') %>
+          </thead>
+          <tbody>
+            <tr>
+              <td class="settings-list-row-control"><%- @T('Endpoint') %>
+              <td class="settings-list-control-cell"><input type="text" name="endpoint" class="form-control form-control--small" value="" placeholder="https://outlook.office365.com/EWS/Exchange.asmx" autocomplete="off" required>
+
+            <tr>
+              <td class="settings-list-row-control"><%- @T('User') %>
+              <td class="settings-list-control-cell"><input type="text" name="user" class="form-control form-control--small js-user" value="" placeholder="" autocomplete="off" required>
+            <tr>
+              <td class="settings-list-row-control"><%- @T('Password') %>
+              <td class="settings-list-control-cell"><input type="password" name="password" class="form-control form-control--small js-password" value="" placeholder="" autocomplete="new-password" required>
+          </tbody>
+        </table>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="modal-rightFooter">
+        <button class="btn btn--primary align-right js-submit"><%- @T('Connect') %></button>
+      </div>
+    </div>
+  </form>
+
+  <form class="modal-content setup wizard hide js-folders">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Folders') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <div class="alert alert--danger hide" role="alert"></div>
+
+        <div class="column_select form-group">
+            <div class="formGroup-label">
+                <label for="folders"><%- @T('Import %s', 'Folders') %></label>
+            </div>
+            <div class="controls js-foldersSelect">
+            </div>
+        </div>
+
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="modal-rightFooter">
+        <a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-bind"><%- @T('Go Back') %></a>
+      </div>
+      <div class="modal-rightFooter">
+        <button class="btn btn--primary align-right js-submitTry"><%- @T('Continue') %></button>
+      </div>
+    </div>
+  </form>
+
+  <form class="modal-content setup wizard hide js-analyze">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <p class="wizard-loadingText">
+          <span class="loading icon"></span> <%- @T('Analyzing structure...') %>
+        </p>
+      </div>
+    </div>
+    <div class="modal-footer"></div>
+  </form>
+
+  <div class="modal-content setup wizard hide js-mapping">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Mapping') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <div class="alert alert--danger hide" role="alert"></div>
+
+        <h2><%- @T('User') %></h2>
+        <form class="js-userMappingForm">
+          <table class="settings-list js-userAttributeMap" style="width: 100%;">
+            <colgroup>
+              <col width="240">
+              <col>
+              <col>
+            </colgroup>
+            <thead>
+              <tr>
+                <th><%- @T('%s Attribute', 'Exchange') %>
+                <th><%- @T('%s Attribute', 'Zammad') %>
+                <th><%- @T('Action') %>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="settings-list-row-control" colspan="3">
+                  <div class="btn btn--text btn--create js-add">
+                    <%- @Icon('plus-small') %> <%- @T('Add') %>
+                  </div>
+            </tbody>
+          </table>
+        </form>
+
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="modal-rightFooter">
+        <a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-bind"><%- @T('Go Back') %></a>
+      </div>
+      <div class="modal-rightFooter">
+        <button class="btn btn--primary align-right js-submitTry"><%- @T('Continue') %></button>
+      </div>
+    </div>
+  </div>
+
+  <form class="modal-content setup wizard hide js-dry">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <div class="js-preprogress">
+          <p class="wizard-loadingText">
+            <span class="loading icon"></span>
+            <%- @T('Counting entries. This may take a while.') %>
+          </p>
+        </div>
+        <div class="js-analyzing hide">
+          <p class="wizard-loadingText">
+            <%- @T('Analyzing entries with given configuration...') %>
+          </p>
+          <div class="centered js-progress">
+            <progress max="" value=""></progress>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer"></div>
+  </form>
+
+  <div class="modal-content setup wizard hide js-try">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %> <%- @T('Configuration') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <div class="alert alert--danger hide" role="alert"></div>
+        <p><%- @T('With your current configuration the following will happen') %>:</p>
+        <div class="js-summary"></div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="modal-rightFooter">
+        <a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-mapping"><%- @T('Go Back') %></a>
+      </div>
+      <div class="modal-rightFooter">
+        <button class="btn btn--primary align-right js-submitSave"><%- @T('Save configuration') %></button>
+      </div>
+    </div>
+  </div>
+
+  <form class="modal-content setup wizard hide js-error">
+    <div class="modal-header">
+      <div class="modal-close js-close">
+        <%- @Icon('diagonal-cross') %>
+      </div>
+      <h1 class="modal-title"><%- @T('Exchange') %></h1>
+    </div>
+    <div class="modal-body">
+      <div class="wizard-body vertical justified">
+        <div class="alert alert--danger hide" role="alert"></div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="modal-rightFooter">
+        <button class="btn btn--primary align-right"><%- @T('Cancel') %></button>
+      </div>
+    </div>
+  </form>
+
+</div>

+ 89 - 0
app/controllers/concerns/integration/import_job_base.rb

@@ -0,0 +1,89 @@
+module Integration::ImportJobBase
+  extend ActiveSupport::Concern
+
+  def job_try_index
+    job_index(
+      dry_run:       true,
+      take_finished: params[:finished] == 'true'
+    )
+  end
+
+  def job_try_create
+    ImportJob.dry_run(name: import_backend_namespace, payload: payload_dry_run)
+    render json: {
+      result: 'ok',
+    }
+  end
+
+  def job_start_index
+    job_index(dry_run: false)
+  end
+
+  def job_start_create
+    if !ImportJob.exists?(name: import_backend_namespace, finished_at: nil)
+      job = ImportJob.create(name: import_backend_namespace, payload: payload_import)
+      job.delay.start
+    end
+    render json: {
+      result: 'ok',
+    }
+  end
+
+  def payload_dry_run
+    params
+  end
+
+  def payload_import
+    import_setting
+  end
+
+  private
+
+  def answer_with
+    result = yield
+    render json: result.merge(result: 'ok')
+  rescue => e
+    logger.error(e)
+    render json: {
+      result:  'failed',
+      message: e.message,
+    }
+  end
+
+  def import_setting
+    Setting.get(import_setting_name)
+  end
+
+  def import_setting_name
+    "#{import_backend_name.downcase}_config"
+  end
+
+  def import_backend_namespace
+    "Import::#{import_backend_name}"
+  end
+
+  def import_backend_name
+    self.class.name.split('::').last.sub('Controller', '')
+  end
+
+  def job_index(dry_run:, take_finished: true)
+    job = ImportJob.find_by(
+      name:        import_backend_namespace,
+      dry_run:     dry_run,
+      finished_at: nil
+    )
+    if !job && take_finished
+      job = ImportJob.where(
+        name:    import_backend_namespace,
+        dry_run: dry_run
+      ).order(created_at: :desc).limit(1).first
+    end
+
+    if job
+      model_show_render_item(job)
+    else
+      render json: {}
+    end
+  end
+
+end

Some files were not shown because too many files changed in this diff