From 5f06c8c6b4126966f7b2605deeff32b9c9b066bb Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Thu, 28 May 2020 15:28:07 +0200 Subject: [PATCH] Fixes #2866 - G Suite accounts will only allow access to apps using OAuth. Password-based access will no longer be supported. --- Gemfile | 1 + Gemfile.lock | 3 + .../_application_controller.coffee | 8 + .../_application_controller_generic.coffee | 21 +- .../app/controllers/_channel/email.coffee | 7 + .../app/controllers/_channel/google.coffee | 425 +++++++++++++++++ .../app/models/email_address.coffee | 14 +- .../channel/email_account_overview.jst.eco | 17 +- .../app/views/google/app_config.jst.eco | 29 ++ .../app/views/google/index.jst.eco | 12 + .../javascripts/app/views/google/list.jst.eco | 116 +++++ .../app/views/layout_ref/content.jst.eco | 3 + .../javascripts/app/views/modal.jst.eco | 3 + app/assets/stylesheets/zammad.scss | 44 +- app/controllers/channels_email_controller.rb | 3 +- app/controllers/channels_google_controller.rb | 100 ++++ ...ap_authentication_migration_cleanup_job.rb | 14 + app/models/channel.rb | 18 +- app/models/channel/driver/imap.rb | 20 +- app/models/channel/driver/smtp.rb | 2 +- app/models/email_address.rb | 2 +- app/models/external_credential.rb | 5 + .../channels_google_controller_policy.rb | 3 + .../external_credentials_controller_policy.rb | 2 +- config/routes/google.rb | 12 + ...ication_migration_cleanup_job_scheduler.rb | 17 + db/seeds/schedulers.rb | 9 + lib/email_helper/probe.rb | 2 +- lib/external_credential/google.rb | 268 +++++++++++ spec/factories/channel.rb | 4 + ...thentication_migration_cleanup_job_spec.rb | 30 ++ spec/lib/external_credential/google_spec.rb | 449 ++++++++++++++++++ spec/requests/integration/gmail_spec.rb | 65 +++ 33 files changed, 1694 insertions(+), 34 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_channel/google.coffee create mode 100644 app/assets/javascripts/app/views/google/app_config.jst.eco create mode 100644 app/assets/javascripts/app/views/google/index.jst.eco create mode 100644 app/assets/javascripts/app/views/google/list.jst.eco create mode 100644 app/controllers/channels_google_controller.rb create mode 100644 app/jobs/imap_authentication_migration_cleanup_job.rb create mode 100644 app/policies/controllers/channels_google_controller_policy.rb create mode 100644 config/routes/google.rb create mode 100644 db/migrate/20200507095900_imap_authentication_migration_cleanup_job_scheduler.rb create mode 100644 lib/external_credential/google.rb create mode 100644 spec/jobs/imap_authentication_migration_cleanup_job_spec.rb create mode 100644 spec/lib/external_credential/google_spec.rb create mode 100644 spec/requests/integration/gmail_spec.rb diff --git a/Gemfile b/Gemfile index 2850c9675..b079a4019 100644 --- a/Gemfile +++ b/Gemfile @@ -82,6 +82,7 @@ gem 'omniauth-twitter' gem 'omniauth-weibo-oauth2' # channels +gem 'gmail_xoauth' gem 'koala' gem 'telegramAPI' gem 'twitter', git: 'https://github.com/sferik/twitter.git' diff --git a/Gemfile.lock b/Gemfile.lock index bb57c333b..a1c9ebe5e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -214,6 +214,8 @@ GEM retriable (~> 2.1) globalid (0.4.2) activesupport (>= 4.2.0) + gmail_xoauth (0.4.2) + oauth (>= 0.3.6) guard (2.15.0) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -600,6 +602,7 @@ DEPENDENCIES factory_bot_rails faker github_changelog_generator + gmail_xoauth guard guard-livereload guard-symlink diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee index 32b24b0b4..f85638e9a 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.coffee @@ -663,6 +663,14 @@ class App.ControllerModal extends App.Controller @clearAlerts() @onSubmit(e) + startLoading: => + @$('.modal-body').addClass('hide') + @$('.modal-loader').removeClass('hide') + + stopLoading: => + @$('.modal-body').removeClass('hide') + @$('.modal-loader').addClass('hide') + class App.SessionMessage extends App.ControllerModal showTrySupport: true diff --git a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee index 18eddfca8..471bc42b6 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee @@ -381,8 +381,9 @@ class App.ControllerTabs extends App.Controller events: 'click .nav-tabs [data-toggle="tab"]': 'tabRemember' - constructor: -> - super + constructor: (params) -> + @originParams = params # remember params for sub-controller + super(params) # check authentication if @requiredPermission @@ -420,7 +421,7 @@ class App.ControllerTabs extends App.Controller params.target = tab.target params.el = @$("##{tab.target}") @controllerList ||= [] - @controllerList.push new tab.controller(params) + @controllerList.push new tab.controller(_.extend(@originParams, params)) # check if tabs need to be show / cant' use .tab(), because tabs are note shown (only one tab exists) if @tabs.length <= 1 @@ -450,7 +451,7 @@ class App.ControllerNavSidbar extends App.Controller @bind('ui:rerender', => @render(true) - @updateNavigation(true) + @updateNavigation(true, params) ) show: (params = {}) => @@ -460,7 +461,7 @@ class App.ControllerNavSidbar extends App.Controller for key, value of params if key isnt 'el' && key isnt 'shown' && key isnt 'match' @[key] = value - @updateNavigation() + @updateNavigation(false, params) if @activeController && _.isFunction(@activeController.show) @activeController.show(params) @@ -482,7 +483,7 @@ class App.ControllerNavSidbar extends App.Controller selectedItem: selectedItem ) - updateNavigation: (force) => + updateNavigation: (force, params) => groups = @groupsSorted() selectedItem = @selectedItem(groups) return if !selectedItem @@ -491,7 +492,7 @@ class App.ControllerNavSidbar extends App.Controller @$('.sidebar li').removeClass('active') @$(".sidebar li a[href=\"#{selectedItem.target}\"]").parent().addClass('active') - @executeController(selectedItem) + @executeController(selectedItem, params) groupsSorted: => @@ -552,16 +553,14 @@ class App.ControllerNavSidbar extends App.Controller selectedItem - executeController: (selectedItem) => + executeController: (selectedItem, params) => if @activeController @activeController.el.remove() @activeController = undefined @$('.main').append('
') - @activeController = new selectedItem.controller( - el: @$('.main div') - ) + @activeController = new selectedItem.controller(_.extend(params, el: @$('.main div'))) setPosition: (position) => return if @shown diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index 4d7f54740..74809e7e3 100644 --- a/app/assets/javascripts/app/controllers/_channel/email.coffee +++ b/app/assets/javascripts/app/controllers/_channel/email.coffee @@ -187,6 +187,7 @@ class App.ChannelEmailAccountOverview extends App.Controller 'click .js-emailAddressEdit': 'emailAddressEdit' 'click .js-emailAddressDelete': 'emailAddressDelete', 'click .js-editNotificationOutbound': 'editNotificationOutbound' + 'click .js-migrateGoogleMail': 'migrateGoogleMail' constructor: -> super @@ -379,6 +380,12 @@ class App.ChannelEmailAccountOverview extends App.Controller channelDriver: @channelDriver ) + migrateGoogleMail: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @navigate "#channels/google/#{id}" + + class App.ChannelEmailEdit extends App.ControllerModal buttonClose: true buttonCancel: true diff --git a/app/assets/javascripts/app/controllers/_channel/google.coffee b/app/assets/javascripts/app/controllers/_channel/google.coffee new file mode 100644 index 000000000..0d47a828c --- /dev/null +++ b/app/assets/javascripts/app/controllers/_channel/google.coffee @@ -0,0 +1,425 @@ +class App.ChannelGoogle extends App.ControllerTabs + requiredPermission: 'admin.channel_email' + header: 'Google' + constructor: -> + super + + @title 'Google', true + + @tabs = [ + { + name: 'Accounts', + target: 'c-account', + controller: ChannelGoogleAccountOverview, + }, + { + name: 'Filter', + target: 'c-filter', + controller: App.ChannelEmailFilter, + }, + { + name: 'Signatures', + target: 'c-signature', + controller: App.ChannelEmailSignature, + }, + { + name: 'Settings', + target: 'c-setting', + controller: App.SettingsArea, + params: { area: 'Email::Base' }, + }, + ] + + @render() + +class ChannelGoogleAccountOverview extends App.ControllerSubContent + requiredPermission: 'admin.channel_google' + events: + 'click .js-new': 'new' + 'click .js-delete': 'delete' + 'click .js-configApp': 'configApp' + 'click .js-disable': 'disable' + 'click .js-enable': 'enable' + 'click .js-channelGroupChange': 'groupChange' + 'click .js-editInbound': 'editInbound' + 'click .js-rollbackMigration': 'rollbackMigration' + 'click .js-emailAddressNew': 'emailAddressNew' + 'click .js-emailAddressEdit': 'emailAddressEdit' + 'click .js-emailAddressDelete': 'emailAddressDelete', + + constructor: -> + super + + #@interval(@load, 60000) + @load() + + load: (reset_channel_id = false) => + if reset_channel_id + @channel_id = undefined + @navigate '#channels/google' + + @startLoading() + @ajax( + id: 'google_index' + type: 'GET' + url: "#{@apiPath}/channels_google" + processData: true + success: (data, status, xhr) => + @stopLoading() + App.Collection.loadAssets(data.assets) + @callbackUrl = data.callback_url + @render(data) + ) + + render: (data) => + + # if no google app is registered, show intro + external_credential = App.ExternalCredential.findByAttribute('name', 'google') + if !external_credential + @html App.view('google/index')() + if @channel_id + @configApp() + return + + channels = [] + for channel_id in data.channel_ids + channel = App.Channel.find(channel_id) + if channel.group_id + channel.group = App.Group.find(channel.group_id) + else + channel.group = '-' + + email_addresses = App.EmailAddress.search(filter: { channel_id: channel.id }) + channel.email_addresses = email_addresses + + channels.push channel + + # on a channel migration we need to auto redirect + # the user to the "Add Account" functionality after + # the filled up the external credentials + if @channel_id + item = App.Channel.find(@channel_id) + if item && item.area != 'Google::Account' + @new() + return + + # get all unlinked email addresses + not_used_email_addresses = [] + for email_address_id in data.not_used_email_address_ids + not_used_email_addresses.push App.EmailAddress.find(email_address_id) + + @html App.view('google/list')( + channels: channels + external_credential: external_credential + not_used_email_addresses: not_used_email_addresses + ) + + # on a channel creation we will auto open the edit + # dialog after the redirect back to zammad to optional + # change the inbound configuration, but not for + # migrated channel because we guess that the inbound configuration + # is already correct for them. + if @channel_id + item = App.Channel.find(@channel_id) + if item && item.area == 'Google::Account' && item.options && item.options.backup_imap_classic is undefined + @editInbound(undefined, @channel_id, true) + @channel_id = undefined + + show: (params) => + for key, value of params + if key isnt 'el' && key isnt 'shown' && key isnt 'match' + @[key] = value + + configApp: => + new AppConfig( + container: @el.parents('.content') + callbackUrl: @callbackUrl + load: @load + ) + + new: (e) -> + window.location.href = "#{@apiPath}/external_credentials/google/link_account" + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + new App.ControllerConfirm( + message: 'Sure?' + callback: => + @ajax( + id: 'google_delete' + type: 'DELETE' + url: "#{@apiPath}/channels_google" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + + disable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'google_disable' + type: 'POST' + url: "#{@apiPath}/channels_google_disable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + enable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'google_enable' + type: 'POST' + url: "#{@apiPath}/channels_google_enable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + editInbound: (e, channel_id, set_active) => + if !channel_id + e.preventDefault() + channel_id = $(e.target).closest('.action').data('id') + item = App.Channel.find(channel_id) + new App.ChannelInboundEdit( + container: @el.closest('.content') + item: item + callback: @load + set_active: set_active, + ) + + rollbackMigration: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'google_rollback_migration' + type: 'POST' + url: "#{@apiPath}/channels_google_rollback_migration" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + @notify + type: 'success' + msg: 'Rollback of channel migration succeeded!' + error: (data) => + @notify + type: 'error' + msg: 'Failed to rollback migration of the channel!' + ) + + groupChange: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + item = App.Channel.find(id) + new App.ChannelGroupEdit( + container: @el.closest('.content') + item: item + callback: @load + ) + + emailAddressNew: (e) => + e.preventDefault() + channel_id = $(e.target).closest('.action').data('id') + new App.ControllerGenericNew( + pageData: + object: 'Email Address' + genericObject: 'EmailAddress' + container: @el.closest('.content') + item: + channel_id: channel_id + callback: @load + ) + + emailAddressEdit: (e) => + e.preventDefault() + id = $(e.target).closest('li').data('id') + new App.ControllerGenericEdit( + pageData: + object: 'Email Address' + genericObject: 'EmailAddress' + container: @el.closest('.content') + id: id + callback: @load + ) + + emailAddressDelete: (e) => + e.preventDefault() + id = $(e.target).closest('li').data('id') + item = App.EmailAddress.find(id) + new App.ControllerGenericDestroyConfirm( + item: item + container: @el.closest('.content') + callback: @load + ) + +class App.ChannelInboundEdit extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: true + head: 'Channel' + + content: => + configureAttributesBase = [ + { name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, placeholder: 'optional' }, + { name: 'options::keep_on_server', display: 'Keep messages on server', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false }, + ] + @form = new App.ControllerForm( + model: + configure_attributes: configureAttributesBase + className: '' + params: @item.options.inbound + ) + @form.form + + onSubmit: (e) => + @startLoading() + + # get params + params = @formParam(e.target) + + # validate form + errors = @form.validate(params) + + # show errors in form + if errors + @log 'error', errors + @formValidate(form: e.target, errors: errors) + return false + + # disable form + @formDisable(e) + + if @set_active + params['active'] = true + + # update + @ajax( + id: 'channel_email_inbound' + type: 'POST' + url: "#{@apiPath}/channels_google_inbound/#{@item.id}" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + @callback(true) + @close() + error: (xhr) => + @stopLoading() + @formEnable(e) + details = xhr.responseJSON || {} + @notify + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to save changes.') + timeout: 6000 + ) + +class App.ChannelGroupEdit extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: true + head: 'Channel' + + content: => + configureAttributesBase = [ + { name: 'group_id', display: 'Destination Group', tag: 'select', null: false, relation: 'Group', nulloption: true, filter: { active: true } }, + ] + @form = new App.ControllerForm( + model: + configure_attributes: configureAttributesBase + className: '' + params: @item + ) + @form.form + + onSubmit: (e) => + + # get params + params = @formParam(e.target) + + # validate form + errors = @form.validate(params) + + # show errors in form + if errors + @log 'error', errors + @formValidate(form: e.target, errors: errors) + return false + + # disable form + @formDisable(e) + + # update + @ajax( + id: 'channel_email_group' + type: 'POST' + url: "#{@apiPath}/channels_google_group/#{@item.id}" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + @callback() + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + @el.find('.alert').removeClass('hidden').text(data.error || 'Unable to save changes.') + ) + +class AppConfig extends App.ControllerModal + head: 'Connect Google App' + shown: true + button: 'Connect' + buttonCancel: true + small: true + + content: -> + @external_credential = App.ExternalCredential.findByAttribute('name', 'google') + content = $(App.view('google/app_config')( + external_credential: @external_credential + callbackUrl: @callbackUrl + )) + content.find('.js-select').on('click', (e) => + @selectAll(e) + ) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + + # verify app credentials + @ajax( + id: 'google_app_verify' + type: 'POST' + url: "#{@apiPath}/external_credentials/google/app_verify" + data: JSON.stringify(@formParams()) + processData: true + success: (data, status, xhr) => + if data.attributes + if !@external_credential + @external_credential = new App.ExternalCredential + @external_credential.load(name: 'google', credentials: data.attributes) + @external_credential.save( + done: => + @isChanged = true + @close() + fail: => + @el.find('.alert').removeClass('hidden').text('Unable to create entry.') + ) + return + @formEnable(e) + @el.find('.alert').removeClass('hidden').text(data.error || 'Unable to verify App.') + ) + +App.Config.set('google', { prio: 5000, name: 'Google', parent: '#channels', target: '#channels/google', controller: App.ChannelGoogle, permission: ['admin.channel_google'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/models/email_address.coffee b/app/assets/javascripts/app/models/email_address.coffee index c44f949a8..b8f4c2089 100644 --- a/app/assets/javascripts/app/models/email_address.coffee +++ b/app/assets/javascripts/app/models/email_address.coffee @@ -3,12 +3,22 @@ class App.EmailAddress extends App.Model @extend Spine.Model.Ajax @url: @apiPath + '/email_addresses' - @filterChannel: (options, type) -> + @filterChannel: (options, type, params) -> return options if type isnt 'collection' + + localChannel = undefined + if params && params.channel_id + if App.Channel.exists(params.channel_id) + localChannel = App.Channel.find(params.channel_id) + _.filter( options (channel) -> - return channel if channel && channel.area is 'Email::Account' + return if !channel + if localChannel + return channel if channel.area is localChannel.area + else + return channel if channel.area is 'Google::Account' || channel.area is 'Email::Account' ) @configure_attributes = [ diff --git a/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco b/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco index cdeb70351..5936e5f95 100644 --- a/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco +++ b/app/assets/javascripts/app/views/channel/email_account_overview.jst.eco @@ -42,6 +42,21 @@ <% else: %> <% for channel in @account_channels: %>
+
@@ -140,7 +155,7 @@
<% if channel.active is true: %> -
<%- @T('Disable') %>
+
<%- @T('Disable') %>
<% else: %>
<%- @T('Enable') %>
<% end %> diff --git a/app/assets/javascripts/app/views/google/app_config.jst.eco b/app/assets/javascripts/app/views/google/app_config.jst.eco new file mode 100644 index 000000000..c20621a5d --- /dev/null +++ b/app/assets/javascripts/app/views/google/app_config.jst.eco @@ -0,0 +1,29 @@ + +

+ <%- @T('The tutorial on how to manage a %s is hosted on our online documentation %l.', 'Google App', 'https://admin-docs.zammad.org/en/latest/channels/google.html') %> +

+
+

<%- @T('Enter your %s App Keys', 'Google') %>

+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+

<%- @T('Your callback URL') %>

+
+
+ +
+
+
diff --git a/app/assets/javascripts/app/views/google/index.jst.eco b/app/assets/javascripts/app/views/google/index.jst.eco new file mode 100644 index 000000000..e477e5147 --- /dev/null +++ b/app/assets/javascripts/app/views/google/index.jst.eco @@ -0,0 +1,12 @@ + + +
+
+

<%- @T('You can connect %s with Zammad. You need to connect your Zammad with %s first.', 'Google Accounts', 'Google') %>

+
<%- @T('Connect Google App') %>
+
+
diff --git a/app/assets/javascripts/app/views/google/list.jst.eco b/app/assets/javascripts/app/views/google/list.jst.eco new file mode 100644 index 000000000..9cbf392aa --- /dev/null +++ b/app/assets/javascripts/app/views/google/list.jst.eco @@ -0,0 +1,116 @@ + + +<% if !_.isEmpty(@not_used_email_addresses): %> +
+
+
+ <%- @T('Notice') %>: <%- @T('Unassigned email addresses, assign them to a channel or delete them.') %> + +
+
+
+<% end %> + +
+<% for channel in @channels: %> +
+
+
+
+

<%- @Icon('status', channel.status_in + " inline") %> <%- @T('Inbound') %>

+ <% if channel.preferences.editable isnt false: %> +
<%- @T('Edit') %>
+ <% end %> +
+ + <% if !_.isEmpty(channel.last_log_in): %> +
+ <%= channel.last_log_in %> +
+ <% end %> + +
+ +

<%- @T('Destination Group') %>

+ + <% groupName = '' %> + <% if channel.group.displayName: %> + <% groupName = channel.group.displayName() %> + <% else: %> + <% groupName = channel.group %> + <% end %> + <% if channel.group.active is false: %> + <%- @T('%s is inactive, please select a active one.', groupName) %> + <% else: %> + <%= groupName %> + <% end %> + +
+ +
+
+

<%- @Icon('status', channel.status_out + " inline") %> <%- @T('Outbound') %>

+
+ + <% if !_.isEmpty(channel.last_log_out): %> +
+ <%= channel.last_log_out %> +
+ <% end %> + +
+ +

<%- @T('Email Address') %>

+
    + <% if !_.isEmpty(channel.email_addresses): %> + <% for email_address in channel.email_addresses: %> +
  • +
    <%= email_address.email %>
    +
    <%- @T('Edit') %>
    + <% if channel.email_addresses.length > 1: %> +
    + <%- @Icon('diagonal-cross') %> +
    + <% end %> + <% end %> + <% else: %> +
  • <%- @T('none') %> + <% end %> +
+ + <%- @T('Add') %> +
+
+ +
+ <% if channel.options.backup_imap_classic: %> +
<%- @T('Rollback migration') %>
+ <% end %> + <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
<%- @T('Delete') %>
+
+
+<% end %> +
diff --git a/app/assets/javascripts/app/views/layout_ref/content.jst.eco b/app/assets/javascripts/app/views/layout_ref/content.jst.eco index 814567d94..68d05777e 100644 --- a/app/assets/javascripts/app/views/layout_ref/content.jst.eco +++ b/app/assets/javascripts/app/views/layout_ref/content.jst.eco @@ -81,6 +81,9 @@ +

Alert with button

+ +
diff --git a/app/assets/javascripts/app/views/modal.jst.eco b/app/assets/javascripts/app/views/modal.jst.eco index 2ef7282a9..bf02ba35e 100644 --- a/app/assets/javascripts/app/views/modal.jst.eco +++ b/app/assets/javascripts/app/views/modal.jst.eco @@ -17,6 +17,9 @@ +