diff --git a/.rubocop.yml b/.rubocop.yml index a897a2c64..e2ed7ca08 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -299,6 +299,9 @@ Naming/VariableName: Naming/VariableNumber: EnforcedStyle: normalcase +Naming/BlockForwarding: + EnforcedStyle: explicit + Style/WordArray: EnforcedStyle: percent MinSize: 0 diff --git a/Gemfile b/Gemfile index 08b8fdf18..bdb0e4ca1 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source 'http://rubygems.org' ruby '3.2.2' +gem 'activerecord-session_store' gem 'bootsnap', require: false gem 'devise', '~> 4.8.1' gem 'devise_invitable' @@ -24,7 +25,9 @@ gem 'omniauth', '~> 2.1' gem 'omniauth-azure-activedirectory-v2' gem 'omniauth-linkedin-oauth2' gem 'omniauth-okta', git: 'https://github.com/scinote-eln/omniauth-okta', branch: 'org_auth_server_support' +gem 'omniauth_openid_connect' gem 'omniauth-rails_csrf_protection', '~> 1.0' +gem 'omniauth-saml' # Gems for API implementation gem 'active_model_serializers', '~> 0.10.7' @@ -92,7 +95,7 @@ gem 'graphviz' gem 'cssbundling-rails' gem 'jsbundling-rails' -gem 'tailwindcss-rails', '~> 2.0' +gem 'tailwindcss-rails', '~> 2.4' gem 'base62' # Used for smart annotations gem 'newrelic_rpm' diff --git a/Gemfile.lock b/Gemfile.lock index 564f94b1d..e998b8757 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,13 @@ GEM activesupport (= 7.0.8.1) activerecord-import (1.4.1) activerecord (>= 4.2) + activerecord-session_store (2.1.0) + actionpack (>= 6.1) + activerecord (>= 6.1) + cgi (>= 0.3.6) + multi_json (~> 1.11, >= 1.11.2) + rack (>= 2.0.8, < 4) + railties (>= 6.1) activestorage (7.0.8.1) actionpack (= 7.0.8.1) activejob (= 7.0.8.1) @@ -141,6 +148,7 @@ GEM railties (>= 3.1) aspector (0.14.0) ast (2.4.2) + attr_required (1.0.1) auto_strip_attributes (2.6.0) activerecord (>= 4.0) awesome_print (1.9.2) @@ -219,6 +227,7 @@ GEM mail case_transform (0.2) activesupport + cgi (0.4.1) childprocess (4.1.0) chunky_png (1.4.0) coderay (1.1.3) @@ -478,6 +487,25 @@ GEM omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) omniauth (~> 2.0) + omniauth-saml (2.1.0) + omniauth (~> 2.0) + ruby-saml (~> 1.12) + omniauth_openid_connect (0.7.1) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + openid_connect (2.2.0) + activemodel + attr_required (>= 1.0.0) + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + net-smtp + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_email + validate_url + webfinger (~> 2.0) orm_adapter (0.5.0) overcommit (0.60.0) childprocess (>= 0.6.3, < 5) @@ -518,6 +546,13 @@ GEM rack (>= 1.0, < 3) rack-cors (2.0.2) rack (>= 2.0.0) + rack-oauth2 (2.2.0) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) rack-protection (3.0.6) rack rack-test (2.1.0) @@ -626,6 +661,9 @@ GEM rubocop (>= 1.33.0, < 2.0) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) + ruby-saml (1.16.0) + nokogiri (>= 1.13.10) + rexml ruby-vips (2.1.4) ffi (~> 1.12) rubyzip (2.3.2) @@ -663,13 +701,18 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) stream (0.5.5) + swd (2.0.2) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects sys-uname (1.2.3) ffi (~> 1.1) - tailwindcss-rails (2.0.29) + tailwindcss-rails (2.4.0) railties (>= 6.0.0) - tailwindcss-rails (2.0.29-arm64-darwin) + tailwindcss-rails (2.4.0-arm64-darwin) railties (>= 6.0.0) - tailwindcss-rails (2.0.29-x86_64-linux) + tailwindcss-rails (2.4.0-x86_64-linux) railties (>= 6.0.0) thor (1.3.1) tilt (2.2.0) @@ -688,6 +731,12 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) uniform_notifier (1.16.0) + validate_email (0.1.6) + activemodel (>= 3.0) + mail (>= 2.2.5) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix uri (0.13.0) version_gem (1.1.3) view_component (3.9.0) @@ -696,6 +745,10 @@ GEM method_source (~> 1.0) warden (1.2.9) rack (>= 2.0.9) + webfinger (2.1.2) + activesupport + faraday (~> 2.0) + faraday-follow_redirects webmock (3.18.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -722,6 +775,7 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10.7) activerecord-import + activerecord-session_store acts_as_list ajax-datatables-rails (~> 0.3.1) aspector @@ -780,6 +834,8 @@ DEPENDENCIES omniauth-linkedin-oauth2 omniauth-okta! omniauth-rails_csrf_protection (~> 1.0) + omniauth-saml + omniauth_openid_connect overcommit pg (~> 1.5) pg_search @@ -814,7 +870,7 @@ DEPENDENCIES simplecov sneaky-save! sprockets-rails - tailwindcss-rails (~> 2.0) + tailwindcss-rails (~> 2.4) timecop turbolinks (~> 5.2.0) tzinfo-data diff --git a/VERSION b/VERSION index 359c41089..7aa332e41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.32.0 +1.33.0 diff --git a/app/assets/javascripts/experiments/show.js b/app/assets/javascripts/experiments/show.js index 650850f7d..b7d438aaa 100644 --- a/app/assets/javascripts/experiments/show.js +++ b/app/assets/javascripts/experiments/show.js @@ -7,7 +7,7 @@ let myModuleUserSelector = '#my_module_user_ids'; var myModuleTagsSelector = '#module-tags-selector'; - $(document).on('submit', '#new-my-module-modal form', (event) => { + $('#experiment-canvas').on('submit', '#new-my-module-modal form', (event) => { event.preventDefault(); $.post({ @@ -16,13 +16,30 @@ my_module: { name: $('#new-my-module-modal input[name="my_module[name]"]').val(), view_mode: $('#new-my-module-modal input[name="my_module[view_mode]"]').val(), - due_date: $('#new-my-module-modal input[name="my_module[due-date]"]').val(), + due_date: $('#new-my-module-modal input[name="my_module[due_date]"]').val(), tag_ids: dropdownSelector.getValues(myModuleTagsSelector), user_ids: dropdownSelector.getValues(myModuleUserSelector) } } }); }); + + $('#experiment-canvas').on('shown.bs.modal', () => { + // disable the submit button by default + $('#new-modal-submit-btn').prop('disabled', true); + + // listen for input event on the my_module_name input field + $(`${newMyModuleModal} #my_module_name`).on('input', function () { + if ($(this).val().trim() !== '') { + // enable the submit button if the input field is populated + $('#new-modal-submit-btn').prop('disabled', false); + } else { + // otherwise, disable it + $('#new-modal-submit-btn').prop('disabled', true); + } + }); + }); + // Modal's submit handler function $(experimentWrapper) .on('ajax:success', newMyModuleModal, function() { diff --git a/app/assets/javascripts/my_modules.js b/app/assets/javascripts/my_modules.js index bdb85372f..586ca8b7d 100644 --- a/app/assets/javascripts/my_modules.js +++ b/app/assets/javascripts/my_modules.js @@ -112,8 +112,8 @@ }, optionLabel: (data) => { if (data.value > 0) { - return ` - ${data.label}`; + return `${data.label}`; } return ` ${data.label + ' '} diff --git a/app/assets/javascripts/my_modules/protocols.js b/app/assets/javascripts/my_modules/protocols.js index ca1f6440c..7d74d1e41 100644 --- a/app/assets/javascripts/my_modules/protocols.js +++ b/app/assets/javascripts/my_modules/protocols.js @@ -329,7 +329,7 @@ function initAccessModal() { function initWrapTables() { const viewMode = new URLSearchParams(window.location.search).get('view_mode'); - if (['archived', 'locked', 'active'].includes(viewMode)) { + if (['archived', 'locked'].includes(viewMode)) { setTimeout(() => { const notesContainerEl = document.getElementById('notes-container'); window.wrapTables(notesContainerEl); diff --git a/app/assets/javascripts/repositories/renderers/view_renderers.js b/app/assets/javascripts/repositories/renderers/view_renderers.js index fd928fb26..6d205771b 100644 --- a/app/assets/javascripts/repositories/renderers/view_renderers.js +++ b/app/assets/javascripts/repositories/renderers/view_renderers.js @@ -32,7 +32,7 @@ $.fn.dataTable.render.defaultRepositoryAssetValue = function() { }; $.fn.dataTable.render.RepositoryTextValue = function(data) { - var text = $(`${data.value.view}`); + const text = $(`${data.value.view}`); text.attr('data-edit-value', data.value.edit); return text.prop('outerHTML'); }; diff --git a/app/assets/javascripts/repository_columns/index.js b/app/assets/javascripts/repository_columns/index.js index d20a124df..d17ad503c 100644 --- a/app/assets/javascripts/repository_columns/index.js +++ b/app/assets/javascripts/repository_columns/index.js @@ -285,12 +285,15 @@ var RepositoryColumns = (function() { let editUrl = $(el).attr('data-edit-column-url'); let destroyUrl = $(el).attr('data-destroy-column-url'); let thederName; + if ($(el).find('.modal-tooltiptext').length > 0) { thederName = $(el).find('.modal-tooltiptext').text(); } else { thederName = el.innerText; } thederName = _.escape(thederName); + + const e2eName = thederName.toLowerCase().replace(' ', '_'); if (['row-name', 'archived-by', 'archived-on'].includes(el.id)) { visClass = ''; @@ -303,24 +306,24 @@ var RepositoryColumns = (function() { destroyButton = ``; } let listItem = `
  • - + - + -
    ${thederName}
    +
    ${thederName}
    ${ - getColumnTypeText(el, colId) || '' + getColumnTypeText(el, colId) || `` } ${destroyButton} diff --git a/app/assets/javascripts/session_end.js b/app/assets/javascripts/session_end.js index 8b64049b3..45f7ab456 100644 --- a/app/assets/javascripts/session_end.js +++ b/app/assets/javascripts/session_end.js @@ -36,7 +36,7 @@ timeoutID = setTimeout(functionCallback, timeoutTime); } - function toogleDocumentTitle(timeString = null) { + function toggleDocumentTitle(timeString = null) { var sleepEmoticon = String.fromCodePoint(0x1F62A); var originalTitle = document.title.split(sleepEmoticon).pop().trim(); @@ -70,21 +70,21 @@ function reviveSession() { $.post($('meta[name=\'revive-url\']').attr('content')); - toogleDocumentTitle(); + toggleDocumentTitle(); window.localStorage.removeItem('sessionEnd'); setSessionTimeout(initializeSessionCountdown, oneSecondTimeout); } function initializeSessionReviveCallbacks() { $('#session-expire').modal().off('hide.bs.modal').on('hide.bs.modal', function() { - if (sessionExpireIn() > 0) { + if (sessionExpireIn() > 0 && sessionExpireIn() < expireLimit) { reviveSession(); } }); // for manual page reload $(window).off('beforeunload').on('beforeunload', function() { - if (sessionExpireIn() > 0) { + if (sessionExpireIn() > 0 && sessionExpireIn() < expireLimit) { reviveSession(); } }); @@ -98,7 +98,7 @@ initializeSessionCountdown(); } else if (expireIn > 0 && expireIn <= expireLimit) { timeString = newTimerStr(expireIn / 1000); - toogleDocumentTitle(timeString); + toggleDocumentTitle(timeString); $('.expiring').text(I18n.t('devise.sessions.expire_modal.session_end_in.header', { time: timeString })); if (!$('#session-expire').hasClass('in')) { @@ -107,7 +107,7 @@ setSessionTimeout(sessionCountdown, oneSecondTimeout); } else if (expireIn <= 0) { - toogleDocumentTitle(); + toggleDocumentTitle(); $('#session-expire').modal('hide'); $('#session-finished').modal(); } @@ -130,7 +130,7 @@ } setSessionTimeout(initializeSessionCountdown, oneSecondTimeout); } else if (expireOn && !event.originalEvent.newValue) { - toogleDocumentTitle(); + toggleDocumentTitle(); } expireOn = event.originalEvent.newValue; diff --git a/app/assets/javascripts/shared/inline_editing.js b/app/assets/javascripts/shared/inline_editing.js index 657fe46a4..0d5241d1c 100644 --- a/app/assets/javascripts/shared/inline_editing.js +++ b/app/assets/javascripts/shared/inline_editing.js @@ -108,8 +108,10 @@ let inlineEditing = (function() { if (response.status === 403) { HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); } else if (response.status === 422) { - HelperModule.flashAlertMsg(response.responseJSON.errors - ? Object.values(response.responseJSON.errors).join(', ') : I18n.t('errors.general'), 'danger'); + const errors = response.responseJSON.errors || response.responseJSON; + if (!errors) { + HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger'); + } } if (!error) error = response.responseJSON.errors[fieldToUpdate]; container.addClass('error'); @@ -155,6 +157,7 @@ let inlineEditing = (function() { if (inputField(container).attr('disabled')) { saveAllEditFields(); let input = inputField(container); + input.val(container.attr('data-original-name')); input.attr('disabled', false) .removeClass('hidden') .focus(); diff --git a/app/assets/javascripts/sitewide/constants.js.erb b/app/assets/javascripts/sitewide/constants.js.erb index 6d3eeff90..ff1416e1e 100644 --- a/app/assets/javascripts/sitewide/constants.js.erb +++ b/app/assets/javascripts/sitewide/constants.js.erb @@ -18,4 +18,6 @@ const GLOBAL_CONSTANTS = { SLOW_STATUS_POLLING_INTERVAL: <%= Constants::SLOW_STATUS_POLLING_INTERVAL %>, ASSET_POLLING_INTERVAL: <%= Constants::ASSET_POLLING_INTERVAL %>, ASSET_SYNC_URL: '<%= Constants::ASSET_SYNC_URL %>', + GLOBAL_SEARCH_PREVIEW_LIMIT: <%= Constants::GLOBAL_SEARCH_PREVIEW_LIMIT %>, + SEARCH_LIMIT: <%= Constants::SEARCH_LIMIT %> }; diff --git a/app/assets/javascripts/sitewide/repository_row_card.js b/app/assets/javascripts/sitewide/repository_row_card.js index d575a8814..18fa9cc9d 100644 --- a/app/assets/javascripts/sitewide/repository_row_card.js +++ b/app/assets/javascripts/sitewide/repository_row_card.js @@ -104,4 +104,8 @@ } } }); + + $(document).on('turbolinks:load', () => { + $('#itemLandingPagelink').trigger('click'); + }); }()); diff --git a/app/assets/javascripts/users/settings/teams/show.js b/app/assets/javascripts/users/settings/teams/show.js index 8d3b65104..051677128 100644 --- a/app/assets/javascripts/users/settings/teams/show.js +++ b/app/assets/javascripts/users/settings/teams/show.js @@ -110,10 +110,9 @@ function(e, data) { // Populate the modal heading & body var modal = $('#destroy-user-team-modal'); - var modalHeading = modal.find('.modal-header').find('.modal-title'); - var modalBody = modal.find('.modal-body'); - modalHeading.text($('
    ').html(data.heading).text()); - modalBody.html(data.html); + const modalContent = modal.find('.modal-content'); + + modalContent.html(data.html); // Show the modal modal.modal('show'); @@ -122,13 +121,14 @@ 'ajax:error', "[data-action='destroy-user-team']", function() { - // TODO + HelperModule.flashAlertMsg(I18n.t('users.settings.user_teams.general_error'), 'danger'); } ); // Also, bind the click action on the modal $('#destroy-user-team-modal') .on('click', "[data-action='submit']", function() { + animateSpinner(); var btn = $(this); var form = btn .closest('.modal') @@ -154,14 +154,16 @@ // Hide the modal modal.modal('hide'); + animateSpinner(null, false); // Reload the whole table - usersDatatable.ajax.reload(); + location.reload(); } ).on( 'ajax:error', "[data-id='destroy-user-team-form']", function() { - // TODO + animateSpinner(null, false); + HelperModule.flashAlertMsg(I18n.t('users.settings.user_teams.general_error'), 'danger'); } ); } diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 500094825..400c99bdf 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -60,3 +60,15 @@ html { .ag-theme-alpine { --ag-font-family: "SN Inter", "Open Sans", Arial, Helvetica, sans-serif !important; } + +.animate-skeleton { + background-image: linear-gradient(90deg, #ddd 0px, #e8e8e8 40px, #ddd 80px); + background-size: 500px; + animation: shine-lines 1.6s infinite linear +} + +@keyframes shine-lines { + 0% { background-position: -150px } + + 40%, 100% { background-position: 320px } +} diff --git a/app/assets/stylesheets/experiment/canvas.scss b/app/assets/stylesheets/experiment/canvas.scss index 9009dffcf..5099d877d 100644 --- a/app/assets/stylesheets/experiment/canvas.scss +++ b/app/assets/stylesheets/experiment/canvas.scss @@ -12,6 +12,12 @@ .panel-heading { padding: 7px 30px 7px 15px; + + .panel-title { + align-items: center; + display: flex; + height: 100%; + } } .panel-body { @@ -97,4 +103,18 @@ grid-template-columns: 1fr; } } + + .bootstrap-select .dropdown-toggle:focus { + outline: none !important; + } + + .filter-option-inner { + height: 100%; + + .filter-option-inner-inner { + align-items: center; + display: flex; + height: 100%; + } + } } diff --git a/app/assets/stylesheets/reports_pdf.sass.scss b/app/assets/stylesheets/reports_pdf.sass.scss index f811678e9..193fff54d 100644 --- a/app/assets/stylesheets/reports_pdf.sass.scss +++ b/app/assets/stylesheets/reports_pdf.sass.scss @@ -28,3 +28,13 @@ thead { display: table-row-group; } +.report-module-repository-element { + .report-element-header { + .repository-name { + max-width: 100vw; + padding-bottom: 4px; + white-space: normal !important; + } + } +} + diff --git a/app/assets/stylesheets/shared/datetime_picker.scss b/app/assets/stylesheets/shared/datetime_picker.scss index 40db129ee..561a5e147 100644 --- a/app/assets/stylesheets/shared/datetime_picker.scss +++ b/app/assets/stylesheets/shared/datetime_picker.scss @@ -116,6 +116,10 @@ .dp__input { line-height: unset; + + &::placeholder { + color: var(--sn-grey); + } } } @@ -147,6 +151,13 @@ height: 36px; } } + + &.borderless-input { + .dp__input { + background-color: transparent; + border-color: transparent; + } + } } .dp__theme_light { @@ -182,7 +193,7 @@ &:hover { border-color: var(--sn-science-blue); } - border-color: var(--sn-science-blue); + border-color: var(--sn-science-blue) !important; } :root { diff --git a/app/assets/stylesheets/shared_styles/elements/checkboxes.scss b/app/assets/stylesheets/shared_styles/elements/checkboxes.scss index abf80f66f..f2a7850fe 100644 --- a/app/assets/stylesheets/shared_styles/elements/checkboxes.scss +++ b/app/assets/stylesheets/shared_styles/elements/checkboxes.scss @@ -35,7 +35,7 @@ input[type="checkbox"].sci-checkbox { &::before { @include font-awesome; animation-timing-function: $timing-function-sharp; - background: $color-white; + background: transparent; border: 1px solid var(--sn-black); border-radius: 1px; color: $color-white; diff --git a/app/assets/stylesheets/tailwind/buttons.css b/app/assets/stylesheets/tailwind/buttons.css index 92a37e2e3..9f7001362 100644 --- a/app/assets/stylesheets/tailwind/buttons.css +++ b/app/assets/stylesheets/tailwind/buttons.css @@ -4,7 +4,7 @@ } .btn { - @apply relative inline-flex items-center text-sm shrink-0 gap-2 justify-center px-4 rounded border border-solid appearance-none whitespace-nowrap cursor-pointer h-[40px] focus:outline-none; + @apply relative inline-flex items-center text-sm shrink-0 gap-2 justify-center px-4 rounded border border-solid appearance-none whitespace-nowrap cursor-pointer h-[40px]; border-color: transparent; } @@ -33,7 +33,7 @@ } .btn.btn-xs.icon-btn { - @apply px-0.5; + @apply px-0.5 w-[30px]; } .btn:hover { @@ -41,7 +41,7 @@ } .btn:focus { - @apply no-underline outline-none text-sn-white; + @apply no-underline outline outline-4 outline-sn-science-blue-hover text-sn-white; } .btn:active { @@ -58,6 +58,11 @@ @apply bg-sn-blue text-sn-white; } + .btn.btn-primary:active, + .btn.btn-primary.active { + @apply bg-sn-blue-click; + } + .btn.btn-primary:hover, .btn.btn-success:hover, .btn.btn-primary:focus, @@ -81,6 +86,11 @@ @apply bg-sn-science-blue text-sn-white border-sn-white; } + .btn.btn-secondary:active, + .btn.btn-secondary.active { + @apply bg-sn-super-light-blue; + } + .btn.btn-secondary:hover, .btn.btn-default:hover, .btn.btn-secondary:focus { @@ -123,6 +133,11 @@ @apply bg-sn-super-light-grey; } + .btn.btn-light:active, + .btn.btn-light.active { + @apply bg-sn-grey-100; + } + .btn.btn-light:disabled, .btn.btn-light.disabled { @apply text-sn-sleepy-grey; @@ -137,6 +152,11 @@ @apply bg-sn-delete-red-hover; } + .btn.btn-danger:active, + .btn.btn-danger.active { + @apply bg-sn-delete-red-click; + } + .btn.btn-danger:disabled, .btn.btn-danger.disabled { @apply bg-sn-delete-red-disabled; diff --git a/app/assets/stylesheets/tailwind/inputs.css b/app/assets/stylesheets/tailwind/inputs.css index aadc8957f..6ea4e568f 100644 --- a/app/assets/stylesheets/tailwind/inputs.css +++ b/app/assets/stylesheets/tailwind/inputs.css @@ -21,7 +21,7 @@ } .sci-input-container-v2 input::placeholder { - @apply text-sn-sleepy-grey; + @apply text-sn-grey; } .sci-input-container-v2 .error { @@ -40,7 +40,8 @@ width: 100%; } - .sci-input-container-v2 input:focus { + .sci-input-container-v2 input:focus, + .sci-input-container-v2 input.active { @apply border-sn-science-blue shadow-none; } @@ -83,7 +84,7 @@ } .sci-input-container-v2 textarea::placeholder { - @apply text-sn-sleepy-grey; + @apply text-sn-grey; } .sci-input-container-v2 textarea:focus { diff --git a/app/controllers/asset_sync_controller.rb b/app/controllers/asset_sync_controller.rb index 855bfba53..7f9e9561e 100644 --- a/app/controllers/asset_sync_controller.rb +++ b/app/controllers/asset_sync_controller.rb @@ -5,7 +5,7 @@ class AssetSyncController < ApplicationController skip_before_action :authenticate_user!, only: %i(update download) skip_before_action :verify_authenticity_token, only: %i(update download) - before_action :authenticate_asset_sync_token!, only: %i(update download) + prepend_before_action :authenticate_asset_sync_token!, only: %i(update download) before_action :check_asset_sync def show @@ -117,7 +117,8 @@ class AssetSyncController < ApplicationController render_error(:unauthorized) and return unless @asset_sync_token&.token_valid? @asset = @asset_sync_token.asset - @current_user = @asset_sync_token.user + + sign_in(@asset_sync_token.user) render_error(:forbidden, @asset.file.filename) and return unless can_manage_asset?(@asset) end diff --git a/app/controllers/dashboard/quick_start_controller.rb b/app/controllers/dashboard/quick_start_controller.rb index 2930b335d..a16cc8f2e 100644 --- a/app/controllers/dashboard/quick_start_controller.rb +++ b/app/controllers/dashboard/quick_start_controller.rb @@ -22,7 +22,9 @@ module Dashboard def project_filter projects = Project.readable_by_user(current_user) - .search(current_user, false, params[:query], 1, current_team) + .search(current_user, false, params[:query], current_team) + .page(params[:page] || 1) + .per(Constants::SEARCH_LIMIT) .select(:id, :name) projects = projects.map { |i| [i.id, escape_input(i.name)] } if (projects.map { |i| i[1] }.exclude? params[:query]) && params[:query].present? @@ -37,7 +39,9 @@ module Dashboard elsif @project experiments = @project.experiments .managable_by_user(current_user) - .search(current_user, false, params[:query], 1, current_team) + .search(current_user, false, params[:query], current_team) + .page(params[:page] || 1) + .per(Constants::SEARCH_LIMIT) .select(:id, :name) experiments = experiments.map { |i| [i.id, escape_input(i.name)] } if (experiments.map { |i| i[1] }.exclude? params[:query]) && diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index bbc91737d..347836601 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class DashboardsController < ApplicationController + include TeamsHelper + + before_action :switch_team_with_param, only: :show + def show @my_module_status_flows = MyModuleStatusFlow.all.preload(my_module_statuses: :my_module_status_consequences) end diff --git a/app/controllers/experiments_controller.rb b/app/controllers/experiments_controller.rb index 96c6e3d1e..11b26bfda 100644 --- a/app/controllers/experiments_controller.rb +++ b/app/controllers/experiments_controller.rb @@ -116,7 +116,7 @@ class ExperimentsController < ApplicationController render json: { message: t('experiments.update.success_flash', experiment: @experiment.name) }, status: :ok else - render json: { message: @experiment.errors.full_messages }, status: :unprocessable_entity + render json: { errors: @experiment.errors }, status: :unprocessable_entity end end @@ -452,6 +452,9 @@ class ExperimentsController < ApplicationController @project = Project.find_by(id: params[:project_id]) render_404 unless @project + + current_team_switch(@project.team) if current_team != @project.team + render_403 unless can_read_project?(@project) end diff --git a/app/controllers/label_templates_controller.rb b/app/controllers/label_templates_controller.rb index 8727edcce..ebd32e464 100644 --- a/app/controllers/label_templates_controller.rb +++ b/app/controllers/label_templates_controller.rb @@ -4,8 +4,8 @@ class LabelTemplatesController < ApplicationController include InputSanitizeHelper include TeamsHelper - before_action :check_feature_enabled, except: %i(index zpl_preview) - before_action :load_label_templates, only: %i(index datatable) + before_action :check_feature_enabled, except: %i(index zpl_preview list) + before_action :load_label_templates, only: %i(index datatable list) before_action :load_label_template, only: %i(show set_default update template_tags) before_action :check_view_permissions, except: %i(create duplicate set_default delete update) before_action :check_manage_permissions, only: %i(create duplicate set_default delete update) @@ -29,6 +29,10 @@ class LabelTemplatesController < ApplicationController end end + def list + render json: @label_templates, each_serializer: LabelTemplateSerializer, user: current_user + end + def show respond_to do |format| format.json { render json: @label_template, serializer: LabelTemplateSerializer, user: current_user } diff --git a/app/controllers/protocols_controller.rb b/app/controllers/protocols_controller.rb index b86cd8ce0..b0ade7fb4 100644 --- a/app/controllers/protocols_controller.rb +++ b/app/controllers/protocols_controller.rb @@ -371,7 +371,11 @@ class ProtocolsController < ApplicationController def save_as_draft Protocol.transaction do - draft = @protocol.save_as_draft(current_user) + draft = nil + + @protocol.with_lock do + draft = @protocol.save_as_draft(current_user) + end if draft.invalid? render json: { error: draft.errors.messages.map { |_, value| value }.join(' ') }, status: :unprocessable_entity diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb index 0a326d5f2..5b0fcacca 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -25,6 +25,7 @@ class RepositoriesController < ApplicationController before_action :check_create_permissions, only: %i(create_modal create) before_action :check_copy_permissions, only: %i(copy_modal copy) before_action :set_inline_name_editing, only: %i(show) + before_action :load_repository_row, only: %i(show) before_action :set_breadcrumbs_items, only: %i(index show) before_action :validate_file_type, only: %i(export_repository export_repositories) @@ -494,6 +495,14 @@ class RepositoriesController < ApplicationController @repositories = current_team.repositories.archived.where(id: params[:repository_ids]) end + def load_repository_row + @repository_row = nil + @repository_row_landing_page = true if params[:landing_page].present? + return if params[:row_id].blank? + + @repository_row = @repository.repository_rows.find_by(id: params[:row_id]) + end + def set_inline_name_editing return unless can_manage_repository?(@repository) @@ -587,11 +596,11 @@ class RepositoriesController < ApplicationController def set_breadcrumbs_items @breadcrumbs_items = [] - archived_branch = @repository&.archived? || (!@repository && params[:archived] == 'true') + archived_branch = @repository&.archived? || (!@repository && params[:view_mode] == 'archived') @breadcrumbs_items.push({ label: t('breadcrumbs.inventories'), - url: archived_branch ? repositories_path(archived: true) : repositories_path, + url: archived_branch ? repositories_path(view_mode: 'archived') : repositories_path, archived: archived_branch }) diff --git a/app/controllers/repository_columns/list_columns_controller.rb b/app/controllers/repository_columns/list_columns_controller.rb index 79d87de78..1117c24d0 100644 --- a/app/controllers/repository_columns/list_columns_controller.rb +++ b/app/controllers/repository_columns/list_columns_controller.rb @@ -32,11 +32,14 @@ module RepositoryColumns end def items - column_list_items = @repository_column.repository_list_items - .where('data ILIKE ?', - "%#{search_params[:query]}%") - .limit(Constants::SEARCH_LIMIT) - .select(:id, :data) + column_list_items = if params[:all_options] + @repository_column.repository_list_items.select(:id, :data) + else + @repository_column.repository_list_items + .where('data ILIKE ?', "%#{search_params[:query]}%") + .order(data: :asc) + .select(:id, :data) + end render json: column_list_items.map { |i| { value: i.id, label: escape_input(i.data) } }, status: :ok end diff --git a/app/controllers/results_controller.rb b/app/controllers/results_controller.rb index 289167312..3be461b3d 100644 --- a/app/controllers/results_controller.rb +++ b/app/controllers/results_controller.rb @@ -176,7 +176,9 @@ class ResultsController < ApplicationController def apply_filters! if params[:query].present? - @results = @results.search(current_user, params[:view_mode] == 'archived', params[:query], params[:page] || 1) + @results = @results.search(current_user, params[:view_mode] == 'archived', params[:query]) + .page(params[:page] || 1) + .per(Constants::SEARCH_LIMIT) end @results = @results.where('results.created_at >= ?', params[:created_at_from]) if params[:created_at_from] diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 09cfc4e51..c0d57f0e3 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -4,302 +4,246 @@ class SearchController < ApplicationController before_action :load_vars, only: :index def index - redirect_to new_search_path unless @search_query + respond_to do |format| + format.html do + redirect_to new_search_path unless @search_query + end + format.json do + redirect_to new_search_path unless @search_query - @search_id = params[:search_id] ? params[:search_id] : generate_search_id + case params[:group] + when 'projects' + search_by_name(Project) - count_search_results + render json: @records.includes(:team, :project_folder), + each_serializer: GlobalSearch::ProjectSerializer, + meta: { + total: @records.total_count, + next_page: (@records.next_page if @records.respond_to?(:next_page)), + } + when 'project_folders' + search_by_name(ProjectFolder) - search_projects if @search_category == :projects - search_project_folders if @search_category == :project_folders - search_experiments if @search_category == :experiments - search_modules if @search_category == :modules - search_results if @search_category == :results - search_tags if @search_category == :tags - search_reports if @search_category == :reports - search_protocols if @search_category == :protocols - search_steps if @search_category == :steps - search_checklists if @search_category == :checklists - if @search_category == :repositories && params[:repository] - search_repository - end - search_assets if @search_category == :assets - search_tables if @search_category == :tables - search_comments if @search_category == :comments + render json: @records.includes(:team, :parent_folder), + each_serializer: GlobalSearch::ProjectFolderSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'reports' + search_by_name(Report) - @search_pages = (@search_count.to_f / Constants::SEARCH_LIMIT.to_f).ceil - @start_page = @search_page - 2 - @start_page = 1 if @start_page < 1 - @end_page = @start_page + 4 + render json: @records.includes(:team, :project, :user), + each_serializer: GlobalSearch::ReportSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'module_protocols' + search_by_name(Protocol, { in_repository: false }) - if @end_page > @search_pages - @end_page = @search_pages - @start_page = @end_page - 4 - @start_page = 1 if @start_page < 1 + render json: @records.joins({ my_module: :experiment }, :team), + each_serializer: GlobalSearch::MyModuleProtocolSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'experiments' + search_by_name(Experiment) + + render json: @records.includes(project: :team), + each_serializer: GlobalSearch::ExperimentSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'tasks' + search_by_name(MyModule) + + render json: @records.includes(experiment: { project: :team }), + each_serializer: GlobalSearch::MyModuleSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'results' + search_by_name(Result) + + render json: @records.includes(my_module: { experiment: { project: :team } }), + each_serializer: GlobalSearch::ResultSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'protocols' + search_by_name(Protocol, { in_repository: true }) + + render json: @records, + each_serializer: GlobalSearch::ProtocolSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'label_templates' + return render json: [], meta: { disabled: true }, status: :ok unless LabelTemplate.enabled? + + search_by_name(LabelTemplate) + + render json: @records, + each_serializer: GlobalSearch::LabelTemplateSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'repository_rows' + search_by_name(RepositoryRow) + + render json: @records, + each_serializer: GlobalSearch::RepositoryRowSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'assets' + search_by_name(Asset) + includes = [{ step: { protocol: { my_module: :experiment } } }, { result: { my_module: :experiment } }, :team] + + render json: @records.includes(includes), + each_serializer: GlobalSearch::AssetSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + end + end end end def new end + def quick + results = if params[:filter].present? + object_quick_search(params[:filter].singularize) + else + Constants::QUICK_SEARCH_SEARCHABLE_OBJECTS.filter_map do |object| + next if object == 'label_template' && !LabelTemplate.enabled? + + object_quick_search(object) + end.flatten.sort_by(&:updated_at).reverse.take(Constants::QUICK_SEARCH_LIMIT) + end + + render json: results, each_serializer: QuickSearchSerializer + end + private + def object_quick_search(class_name) + search_model = class_name.to_s.camelize.constantize + search_method = search_model.method(search_model.respond_to?(:code) ? :search_by_name_and_id : :search_by_name) + + search_method.call(current_user, + current_team, + params[:query], + limit: Constants::QUICK_SEARCH_LIMIT) + .order(updated_at: :desc) + end + def load_vars query = (params.fetch(:q) { '' }).strip - @search_category = params[:category] || '' - @search_category = @search_category.to_sym - @search_page = params[:page].to_i || 1 - @search_case = params[:match_case] == 'true' - @search_whole_word = params[:whole_word] == 'true' - @search_whole_phrase = params[:whole_phrase] == 'true' + @filters = params[:filters] + @include_archived = @filters.blank? || @filters[:include_archived] == 'true' + @teams = (@filters.present? && @filters[:teams]&.values) || current_user.teams @display_query = query - if @search_whole_phrase || query.count(' ').zero? - if query.length < Constants::NAME_MIN_LENGTH - flash[:error] = t('general.query.length_too_short', - min_length: Constants::NAME_MIN_LENGTH) - redirect_back(fallback_location: root_path) - elsif query.length > Constants::TEXT_MAX_LENGTH - flash[:error] = t('general.query.length_too_long', - max_length: Constants::TEXT_MAX_LENGTH) - redirect_back(fallback_location: root_path) - else - @search_query = query - end - else - # splits the search query to validate all entries - splited_query = query.split - @search_query = '' - splited_query.each_with_index do |w, i| - if w.length >= Constants::NAME_MIN_LENGTH && - w.length <= Constants::TEXT_MAX_LENGTH - @search_query += "#{splited_query[i]} " - end - end - if @search_query.blank? - flash[:error] = t('general.query.wrong_query', - min_length: Constants::NAME_MIN_LENGTH, - max_length: Constants::TEXT_MAX_LENGTH) - redirect_back(fallback_location: root_path) - else - @search_query.strip! + splited_query = query.split + @search_query = '' + splited_query.each_with_index do |w, i| + if w.length >= Constants::NAME_MIN_LENGTH && + w.length <= Constants::TEXT_MAX_LENGTH + @search_query += "#{splited_query[i]} " end end - @search_page = 1 if @search_page < 1 + if @search_query.blank? + flash[:error] = t('general.query.wrong_query', + min_length: Constants::NAME_MIN_LENGTH, + max_length: Constants::TEXT_MAX_LENGTH) + redirect_back(fallback_location: root_path) + else + @search_query.strip! + end end protected - def generate_search_id - SecureRandom.urlsafe_base64(32) + def search_by_name(model, options = {}) + @records = model.search(current_user, + @include_archived, + @search_query, + nil, + teams: @teams, + users: @users, + options: options) + + filter_records(model) if @filters.present? + sort_records + paginate_records end - def search_by_name(model) - model.search(current_user, - true, - @search_query, - @search_page, - nil, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase) - .order(created_at: :desc) + def filter_records(model) + filter_datetime!(model, :created_at) if @filters[:created_at].present? + filter_datetime!(model, :updated_at) if @filters[:updated_at].present? + filter_users!(model) if @filters[:users].present? end - def count_by_name(model) - model.search(current_user, - true, - @search_query, - Constants::SEARCH_NO_LIMIT, - nil, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase).size + def sort_records + @records = case params[:sort] + when 'atoz' + @records.order(name: :asc) + when 'ztoa' + @records.order(name: :desc) + when 'created_asc' + @records.order(created_at: :asc) + else + @records.order(created_at: :desc) + end end - def count_by_repository - @repository_search_count = - Rails.cache.fetch("#{@search_id}/repository_search_count", - expires_in: 5.minutes) do - search_count = {} - search_results = Repository.search(current_user, - @search_query, - Constants::SEARCH_NO_LIMIT, - nil, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase) + def paginate_records + @records = if params[:preview] == 'true' + @records.page(params[:page]).per(Constants::GLOBAL_SEARCH_PREVIEW_LIMIT) + else + @records.page(params[:page]).per(Constants::SEARCH_LIMIT) + end + end - current_user.teams.includes(:repositories).each do |team| - team_results = {} - team_results[:team] = team - team_results[:count] = 0 - team_results[:repositories] = {} - Repository.accessible_by_teams(team).each do |repository| - repository_results = {} - repository_results[:id] = repository.id - repository_results[:repository] = repository - repository_results[:count] = 0 - search_results.each do |result| - repository_results[:count] += result.counter if repository.id == result.id - end - team_results[:repositories][repository.name] = repository_results - team_results[:count] += repository_results[:count] - end - search_count[team.name] = team_results - end - search_count - end - - count_total = 0 - @repository_search_count.each_value do |team_results| - count_total += team_results[:count] + def filter_datetime!(model, attribute) + model_name = model.model_name.collection + if @filters[attribute][:on].present? + from_date = Time.zone.parse(@filters[attribute][:on]).beginning_of_day.utc + to_date = Time.zone.parse(@filters[attribute][:on]).end_of_day.utc + elsif @filters[attribute][:from].present? && @filters[attribute][:to].present? + from_date = Time.zone.parse(@filters[attribute][:from]) + to_date = Time.zone.parse(@filters[attribute][:to]) end - count_total + + @records = @records.where("#{model_name}.#{attribute} >= ?", from_date) if from_date.present? + @records = @records.where("#{model_name}.#{attribute} <= ?", to_date) if to_date.present? end - def current_repository_search_count - @repository_search_count.each_value do |counter| - res = counter[:repositories].values.detect do |rep| - rep[:id] == @repository.id - end - return res[:count] if res && res[:count] - end - end - - def count_search_results - @project_search_count = fetch_cached_count Project - @project_folder_search_count = fetch_cached_count ProjectFolder - @experiment_search_count = fetch_cached_count Experiment - @module_search_count = fetch_cached_count MyModule - @result_search_count = fetch_cached_count Result - @tag_search_count = fetch_cached_count Tag - @report_search_count = fetch_cached_count Report - @protocol_search_count = fetch_cached_count Protocol - @step_search_count = fetch_cached_count Step - @checklist_search_count = fetch_cached_count Checklist - @repository_search_count_total = count_by_repository - @asset_search_count = fetch_cached_count Asset - @table_search_count = fetch_cached_count Table - @comment_search_count = fetch_cached_count Comment - - @search_results_count = @project_search_count - @search_results_count += @project_folder_search_count - @search_results_count += @experiment_search_count - @search_results_count += @module_search_count - @search_results_count += @result_search_count - @search_results_count += @tag_search_count - @search_results_count += @report_search_count - @search_results_count += @protocol_search_count - @search_results_count += @step_search_count - @search_results_count += @checklist_search_count - @search_results_count += @repository_search_count_total - @search_results_count += @asset_search_count - @search_results_count += @table_search_count - @search_results_count += @comment_search_count - end - - def fetch_cached_count(type) - exp = 5.minutes - Rails.cache.fetch( - "#{@search_id}/#{type.name.underscore}_search_count", expires_in: exp - ) do - count_by_name type - end - end - - def search_projects - @project_results = [] - @project_results = search_by_name(Project) if @project_search_count.positive? - @search_count = @project_search_count - end - - def search_project_folders - @project_folder_results = [] - @project_folder_results = search_by_name(ProjectFolder) if @project_folder_search_count.positive? - @search_count = @project_folder_search_count - end - - def search_experiments - @experiment_results = [] - @experiment_results = search_by_name(Experiment) if @experiment_search_count.positive? - @search_count = @experiment_search_count - end - - def search_modules - @module_results = [] - @module_results = search_by_name(MyModule) if @module_search_count.positive? - @search_count = @module_search_count - end - - def search_results - @result_results = [] - @result_results = search_by_name(Result) if @result_search_count.positive? - @search_count = @result_search_count - end - - def search_tags - @tag_results = [] - @tag_results = search_by_name(Tag) if @tag_search_count.positive? - @search_count = @tag_search_count - end - - def search_reports - @report_results = [] - @report_results = search_by_name(Report) if @report_search_count.positive? - @search_count = @report_search_count - end - - def search_protocols - @protocol_results = [] - @protocol_results = search_by_name(Protocol) if @protocol_search_count.positive? - @search_count = @protocol_search_count - end - - def search_steps - @step_results = [] - @step_results = search_by_name(Step) if @step_search_count.positive? - @search_count = @step_search_count - end - - def search_checklists - @checklist_results = [] - @checklist_results = search_by_name(Checklist) if @checklist_search_count.positive? - @search_count = @checklist_search_count - end - - def search_repository - @repository = Repository.find_by(id: params[:repository]) - unless current_user.teams.include?(@repository.team) || @repository.private_shared_with?(current_user.teams) - render_403 - end - @repository_results = [] - if @repository_search_count_total.positive? - @repository_results = - Repository.search(current_user, @search_query, @search_page, - @repository, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase) - end - @search_count = current_repository_search_count - end - - def search_assets - @asset_results = [] - @asset_results = search_by_name(Asset) if @asset_search_count.positive? - @search_count = @asset_search_count - end - - def search_tables - @table_results = [] - @table_results = search_by_name(Table) if @table_search_count.positive? - @search_count = @table_search_count - end - - def search_comments - @comment_results = [] - @comment_results = search_by_name(Comment) if @comment_search_count.positive? - @search_count = @comment_search_count + def filter_users!(model) + @records = @records.joins("INNER JOIN activities ON #{model.model_name.collection}.id = activities.subject_id + AND activities.subject_type= '#{model.name}'") + .where('activities.owner_id': @filters[:users]&.values) end end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 965f079ef..36b9c2c12 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -9,9 +9,23 @@ class TeamsController < ApplicationController before_action :load_vars, only: %i(sidebar export_projects export_projects_modal disable_tasks_sharing_modal shared_tasks_toggle) before_action :load_current_folder, only: :sidebar - before_action :check_read_permissions, except: :view_type + before_action :check_read_permissions, except: %i(view_type visible_teams visible_users) before_action :check_export_projects_permissions, only: %i(export_projects_modal export_projects) + def visible_teams + teams = current_user.teams + render json: teams, each_serializer: TeamSerializer + end + + def visible_users + teams = current_user.teams + if params[:teams].present? + teams = teams.where(id: params[:teams]) + end + users = User.where(id: teams.joins(:users).select('users.id')).order(:full_name) + render json: users, each_serializer: UserSerializer, user: current_user + end + def sidebar render json: { html: render_to_string( diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index d0bc18a6a..6fefaf3c7 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -8,7 +8,7 @@ module Users skip_before_action :verify_authenticity_token before_action :sign_up_with_provider_enabled?, only: :linkedin - before_action :check_sso_status, only: %i(customazureactivedirectory okta) + before_action :check_sso_status, only: %i(customazureactivedirectory okta openid_connect) # You should configure your model like this: # devise :omniauthable, omniauth_providers: [:twitter] @@ -46,17 +46,7 @@ module Users if user.blank? # Create new user and identity - full_name = "#{auth.info.first_name} #{auth.info.last_name}" - user = User.new(full_name: full_name, - initials: generate_initials(full_name), - email: email, - password: generate_user_password) - User.transaction do - user.save! - user.user_identities.create!(provider: auth.provider, uid: auth.uid) - user.update!(confirmed_at: user.created_at) - end - + user = create_user_from_auth(email, auth) sign_in_and_redirect(user, event: :authentication) elsif provider_conf['auto_link_on_sign_in'] # Link to existing local account @@ -147,16 +137,7 @@ module Users user = User.find_by(email: auth.info.email.downcase) if user.blank? - # Create new user and identity - user = User.new(full_name: auth.info.name, - initials: generate_initials(auth.info.name), - email: auth.info.email, - password: generate_user_password) - User.transaction do - user.save! - user.user_identities.create!(provider: auth.provider, uid: auth.uid) - user.update!(confirmed_at: user.created_at) - end + user = create_user_from_auth(email, auth) else # Link to existing local account user.user_identities.create!(provider: auth.provider, uid: auth.uid) @@ -177,6 +158,107 @@ module Users end end + def openid_connect + auth = request.env['omniauth.auth'] + settings = ApplicationSettings.instance + provider_conf = settings.values['openid_connect'] + raise StandardError, 'No matching OpenID Connect AD provider config found' if provider_conf.blank? + + return redirect_to connected_accounts_path if current_user + + email = auth.info.email + email ||= auth.dig(:extra, :raw_info, :id_token_claims, :emails)&.first + user = User.from_omniauth(auth) + + # User found in database so just signing in + return sign_in_and_redirect(user) if user.present? + + if email.blank? + # No email in the token so can not link or create user + error_message = I18n.t('devise.openid_connect.errors.no_email') + return redirect_to after_omniauth_failure_path_for(resource_name) + end + + user = User.find_by(email: email.downcase) + + if user.blank? + # Create new user and identity + user = create_user_from_auth(email, auth) + sign_in_and_redirect(user) + elsif provider_conf['auto_link_on_sign_in'] + # Link to existing local account + user.user_identities.create!(provider: auth.provider, uid: auth.uid) + user.update!(confirmed_at: user.created_at) if user.confirmed_at.blank? + sign_in_and_redirect(user) + else + # Cannot do anything with it, so just return an error + error_message = I18n.t('devise.openid_connect.errors.no_local_user_map') + redirect_to after_omniauth_failure_path_for(resource_name) + end + rescue StandardError => e + Rails.logger.error e.message + Rails.logger.error e.backtrace.join("\n") + error_message = I18n.t('devise.openid_connect.errors.failed_to_save') if e.is_a?(ActiveRecord::RecordInvalid) + error_message ||= I18n.t('devise.openid_connect.errors.generic') + redirect_to after_omniauth_failure_path_for(resource_name) + ensure + if error_message + set_flash_message(:alert, :failure, kind: I18n.t('devise.openid_connect.provider_name'), reason: error_message) + else + set_flash_message(:notice, :success, kind: I18n.t('devise.openid_connect.provider_name')) + end + end + + def saml + auth = request.env['omniauth.auth'] + + settings = ApplicationSettings.instance + provider_conf = settings.values['saml'] + raise StandardError, 'No matching SAML provider config found' if provider_conf.blank? + + return redirect_to connected_accounts_path if current_user + + email = auth.info.email + user = User.from_omniauth(auth) + + # User found in database so just signing in + return sign_in_and_redirect(user) if user.present? + + if email.blank? + # No email in the token so can not link or create user + error_message = I18n.t('devise.saml.errors.no_email') + return redirect_to after_omniauth_failure_path_for(resource_name) + end + + user = User.find_by(email: email.downcase) + + if user.blank? + user = create_user_from_auth(email, auth) + sign_in_and_redirect(user) + elsif provider_conf['auto_link_on_sign_in'] + # Link to existing local account + user.user_identities.create!(provider: auth.provider, uid: auth.uid) + user.update!(confirmed_at: user.created_at) if user.confirmed_at.blank? + sign_in_and_redirect(user) + else + # Cannot do anything with it, so just return an error + error_message = I18n.t('devise.saml.errors.no_local_user_map') + redirect_to after_omniauth_failure_path_for(resource_name) + end + rescue StandardError => e + Rails.logger.error e.message + Rails.logger.error e.backtrace.join("\n") + error_message = I18n.t('devise.saml.errors.failed_to_save') if e.is_a?(ActiveRecord::RecordInvalid) + error_message ||= I18n.t('devise.saml.errors.generic') + redirect_to after_omniauth_failure_path_for(resource_name) + ensure + if error_message + set_flash_message(:alert, :failure, kind: I18n.t('devise.saml.provider_name'), reason: error_message) + else + set_flash_message(:notice, :success, kind: I18n.t('devise.saml.provider_name')) + end + end + # More info at: # https://github.com/plataformatec/devise#omniauth @@ -213,5 +295,33 @@ module Users initials = initials.strip.blank? ? 'PLCH' : initials[0..3] initials end + + def create_user_from_auth(email, auth) + full_name = "#{auth.info.first_name} #{auth.info.last_name}" + user = User.new(full_name: full_name, + initials: generate_initials(full_name), + email: email, + password: generate_user_password) + User.transaction do + user.save! + user.user_identities.create!(provider: auth.provider, uid: auth.uid) + user.update!(confirmed_at: user.created_at) + end + user + end + + def create_user_from_auth(email, auth) + full_name = "#{auth.info.first_name} #{auth.info.last_name}" + user = User.new(full_name: full_name, + initials: generate_initials(full_name), + email: email, + password: generate_user_password) + User.transaction do + user.save! + user.user_identities.create!(provider: auth.provider, uid: auth.uid) + user.update!(confirmed_at: user.created_at) + end + user + end end end diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb index 30df7aaa9..6bac88f13 100644 --- a/app/controllers/users/passwords_controller.rb +++ b/app/controllers/users/passwords_controller.rb @@ -21,7 +21,7 @@ class Users::PasswordsController < Devise::PasswordsController if resource.errors.blank? resource.unlock_access! if unlockable?(resource) - if !resource.two_factor_auth_enabled? + if !two_factor_auth_enabled_for(resource) flash_message = resource.active_for_authentication? ? :updated : :updated_not_active set_flash_message!(:notice, flash_message) resource.after_database_authentication @@ -39,7 +39,11 @@ class Users::PasswordsController < Devise::PasswordsController protected def after_resetting_password_path_for(resource) - resource.two_factor_auth_enabled? ? new_session_path(resource_name) : after_sign_in_path_for(resource) + two_factor_auth_enabled_for(resource) ? new_session_path(resource_name) : after_sign_in_path_for(resource) + end + + def two_factor_auth_enabled_for(user) + user.two_factor_auth_enabled? end # The path used after sending reset password instructions diff --git a/app/controllers/users/settings/user_teams_controller.rb b/app/controllers/users/settings/user_teams_controller.rb index 9118f81d4..536dac21b 100644 --- a/app/controllers/users/settings/user_teams_controller.rb +++ b/app/controllers/users/settings/user_teams_controller.rb @@ -48,14 +48,9 @@ module Users render json: { html: render_to_string( partial: 'users/settings/user_teams/' \ - 'destroy_user_team_modal_body', + 'destroy_user_team_modal_body', locals: { user_assignment: @user_assignment }, formats: :html - ), - heading: I18n.t( - 'users.settings.user_teams.destroy_uo_heading', - user: escape_input(@user_assignment.user.full_name), - team: escape_input(@user_assignment.assignable.name) ) } end @@ -63,29 +58,12 @@ module Users def destroy # If user is last administrator of team, # he/she cannot be deleted from it. - invalid = - managing_team_user_roles_collection.include?(@user_assignment.user_role) && - @user_assignment - .assignable - .user_assignments - .where(user_role: managing_team_user_roles_collection) - .count <= 1 + invalid = @user_assignment.last_with_permission?(TeamPermissions::USERS_MANAGE) unless invalid begin @user_assignment.transaction do - # If user leaves on his/her own accord, - # new owner for projects is the first - # administrator of team if params[:leave] - new_owner = - @user_assignment - .assignable - .user_assignments - .where(user_role: managing_team_user_roles_collection) - .where.not(id: @user_assignment.id) - .first - .user Activities::CreateActivityService .call(activity_type: :user_leave_team, owner: current_user, @@ -95,10 +73,6 @@ module Users team: @user_assignment.assignable.id }) else - # Otherwise, the new owner for projects is - # the current user (= an administrator removing - # the user from the team) - new_owner = current_user Activities::CreateActivityService .call(activity_type: :remove_user_from_team, owner: current_user, @@ -110,8 +84,7 @@ module Users }) end reset_user_current_team(@user_assignment) - - remove_user_from_team!(@user_assignment, new_owner) + @user_assignment.destroy! end rescue StandardError => e Rails.logger.error e.message @@ -119,21 +92,27 @@ module Users end end - if !invalid - if params[:leave] - flash[:notice] = I18n.t( - 'users.settings.user_teams.leave_flash', - team: @user_assignment.assignable.name - ) - flash.keep(:notice) - end + if invalid + render json: @user_assignment.errors, status: :unprocessable_entity + else + flash[:success] = if params[:leave] + I18n.t( + 'users.settings.user_teams.leave_flash', + team: @user_assignment.assignable.name + ) + else + I18n.t( + 'users.settings.user_teams.remove_flash', + user: @user_assignment.user.full_name, + team: @user_assignment.assignable.name + ) + end + generate_notification(current_user, @user_assignment.user, @user_assignment.assignable, false) render json: { status: :ok } - else - render json: @user_assignment.errors, status: :unprocessable_entity end end @@ -165,33 +144,6 @@ module Users user_assignment.user.current_team_id = ids.first user_assignment.user.save end - - def remove_user_from_team!(user_assignment, new_owner) - return user_assignment.destroy! unless new_owner - - # Also, make new owner author of all protocols that belong - # to the departing user and belong to this team. - p_ids = user_assignment.user.added_protocols.where(team: user_assignment.assignable).pluck(:id) - Protocol.where(id: p_ids).find_each do |protocol| - protocol.record_timestamps = false - protocol.added_by = new_owner - protocol.archived_by = new_owner if protocol.archived_by == user_assignment.user - protocol.restored_by = new_owner if protocol.restored_by == user_assignment.user - protocol.save!(validate: false) - protocol.user_assignments.find_by(user: new_owner)&.destroy! - protocol.user_assignments.create!( - user: new_owner, - user_role: UserRole.find_predefined_owner_role, - assigned: :manually - ) - end - - # Make new owner author of all inventory items that were added - # by departing user and belong to this team. - RepositoryRow.change_owner(user_assignment.assignable, user_assignment.user, new_owner) - - user_assignment.destroy! - end end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e84f9a003..8bb1ae8cd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -199,12 +199,21 @@ module ApplicationHelper ENV['SSO_ENABLED'] == 'true' end - def okta_configured? - ApplicationSettings.instance.values['okta'].present? + def okta_enabled? + ApplicationSettings.instance.values.dig('okta', 'enabled') end - def azure_ad_configured? - ApplicationSettings.instance.values['azure_ad_apps'].present? + def azure_ad_enabled? + provider_conf = ApplicationSettings.instance.values['azure_ad_apps'] + provider_conf.present? && provider_conf[0]['enabled'] + end + + def saml_enabled? + ApplicationSettings.instance.values.dig('saml', 'enabled') + end + + def openid_connect_enabled? + ApplicationSettings.instance.values.dig('openid_connect', 'enabled') end def wopi_enabled? @@ -213,7 +222,7 @@ module ApplicationHelper # Check whether the wopi file can be edited and return appropriate response def wopi_file_edit_button_status(asset) - file_ext = asset.file_name.split('.').last + file_ext = asset.file_name.split('.').last&.downcase if Constants::WOPI_EDITABLE_FORMATS.include?(file_ext) edit_supported = true title = '' diff --git a/app/helpers/file_icons_helper.rb b/app/helpers/file_icons_helper.rb index b119d078b..7c01548d3 100644 --- a/app/helpers/file_icons_helper.rb +++ b/app/helpers/file_icons_helper.rb @@ -62,7 +62,7 @@ module FileIconsHelper # For showing in view/edit icon url (WOPI) def file_application_url(asset) - file_ext = asset.file_name.split('.').last + file_ext = asset.file_name.split('.').last&.downcase if Constants::FILE_TEXT_FORMATS.include?(file_ext) 'icon_small/docx_file.svg' elsif Constants::FILE_TABLE_FORMATS.include?(file_ext) @@ -73,7 +73,7 @@ module FileIconsHelper end def sn_icon_for(asset) - file_ext = asset.file_name.split('.').last + file_ext = asset.file_name.split('.').last&.downcase if Constants::FILE_TEXT_FORMATS.include?(file_ext) 'file-word' elsif Constants::FILE_TABLE_FORMATS.include?(file_ext) @@ -95,7 +95,7 @@ module FileIconsHelper # Shows correct WOPI application text (Word Online/Excel ..) def wopi_button_text(asset, action) - file_ext = asset.file_name.split('.').last + file_ext = asset.file_name.split('.').last&.downcase if Constants::FILE_TEXT_FORMATS.include?(file_ext) app = I18n.t('result_assets.wopi_word') elsif Constants::FILE_TABLE_FORMATS.include?(file_ext) diff --git a/app/javascript/packs/vue/global_search.js b/app/javascript/packs/vue/global_search.js new file mode 100644 index 000000000..d315a598e --- /dev/null +++ b/app/javascript/packs/vue/global_search.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue/dist/vue.esm-bundler.js'; +import PerfectScrollbar from 'vue3-perfect-scrollbar'; +import GlobalSearch from '../../vue/global_search/container.vue'; +import { mountWithTurbolinks } from './helpers/turbolinks.js'; + +const app = createApp(); +app.component('global_search', GlobalSearch); +app.config.globalProperties.i18n = window.I18n; +app.use(PerfectScrollbar); +mountWithTurbolinks(app, '#GlobalSearch'); diff --git a/app/javascript/packs/vue/repository_item_error_sidebar.js b/app/javascript/packs/vue/repository_item_error_sidebar.js new file mode 100644 index 000000000..ebe9a4c41 --- /dev/null +++ b/app/javascript/packs/vue/repository_item_error_sidebar.js @@ -0,0 +1,12 @@ +/* global */ + +import PerfectScrollbar from 'vue3-perfect-scrollbar'; +import { createApp } from 'vue/dist/vue.esm-bundler.js'; +import { mountWithTurbolinks } from './helpers/turbolinks.js'; +import RepositoryItemErrorSidebar from '../../vue/repository_item_sidebar/RepositoryItemErrorSidebar.vue'; + +const app = createApp({}); +app.component('RepositoryItemErrorSidebar', RepositoryItemErrorSidebar); +app.use(PerfectScrollbar); +app.config.globalProperties.i18n = window.I18n; +mountWithTurbolinks(app, '#repositoryItemErrorSidebar'); diff --git a/app/javascript/vue/experiments/card.vue b/app/javascript/vue/experiments/card.vue index 0a1be59ab..ebfb3f9c6 100644 --- a/app/javascript/vue/experiments/card.vue +++ b/app/javascript/vue/experiments/card.vue @@ -1,6 +1,6 @@ diff --git a/app/javascript/vue/global_search/filters.vue b/app/javascript/vue/global_search/filters.vue new file mode 100644 index 000000000..b23f288a7 --- /dev/null +++ b/app/javascript/vue/global_search/filters.vue @@ -0,0 +1,234 @@ + + + diff --git a/app/javascript/vue/global_search/filters/date.vue b/app/javascript/vue/global_search/filters/date.vue new file mode 100644 index 000000000..d716d0458 --- /dev/null +++ b/app/javascript/vue/global_search/filters/date.vue @@ -0,0 +1,152 @@ + + + diff --git a/app/javascript/vue/global_search/filters_modal.vue b/app/javascript/vue/global_search/filters_modal.vue new file mode 100644 index 000000000..420e8bf00 --- /dev/null +++ b/app/javascript/vue/global_search/filters_modal.vue @@ -0,0 +1,49 @@ + + + diff --git a/app/javascript/vue/global_search/groups/assets.vue b/app/javascript/vue/global_search/groups/assets.vue new file mode 100644 index 000000000..6d5475299 --- /dev/null +++ b/app/javascript/vue/global_search/groups/assets.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/javascript/vue/global_search/groups/experiments.vue b/app/javascript/vue/global_search/groups/experiments.vue new file mode 100644 index 000000000..014e45222 --- /dev/null +++ b/app/javascript/vue/global_search/groups/experiments.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/javascript/vue/global_search/groups/folders.vue b/app/javascript/vue/global_search/groups/folders.vue new file mode 100644 index 000000000..fbb5c1940 --- /dev/null +++ b/app/javascript/vue/global_search/groups/folders.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/javascript/vue/global_search/groups/helpers/cell_template.vue b/app/javascript/vue/global_search/groups/helpers/cell_template.vue new file mode 100644 index 000000000..724b04084 --- /dev/null +++ b/app/javascript/vue/global_search/groups/helpers/cell_template.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/javascript/vue/global_search/groups/helpers/link_template.vue b/app/javascript/vue/global_search/groups/helpers/link_template.vue new file mode 100644 index 000000000..831cdd1c7 --- /dev/null +++ b/app/javascript/vue/global_search/groups/helpers/link_template.vue @@ -0,0 +1,30 @@ + + + diff --git a/app/javascript/vue/global_search/groups/helpers/list_end.vue b/app/javascript/vue/global_search/groups/helpers/list_end.vue new file mode 100644 index 000000000..262d9c06b --- /dev/null +++ b/app/javascript/vue/global_search/groups/helpers/list_end.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/javascript/vue/global_search/groups/helpers/no_search_result.vue b/app/javascript/vue/global_search/groups/helpers/no_search_result.vue new file mode 100644 index 000000000..6a89405c3 --- /dev/null +++ b/app/javascript/vue/global_search/groups/helpers/no_search_result.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/javascript/vue/global_search/groups/helpers/sort_flyout.vue b/app/javascript/vue/global_search/groups/helpers/sort_flyout.vue new file mode 100644 index 000000000..cb4a8e26d --- /dev/null +++ b/app/javascript/vue/global_search/groups/helpers/sort_flyout.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/javascript/vue/global_search/groups/label_templates.vue b/app/javascript/vue/global_search/groups/label_templates.vue new file mode 100644 index 000000000..39070f8eb --- /dev/null +++ b/app/javascript/vue/global_search/groups/label_templates.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/javascript/vue/global_search/groups/my_module_protocols.vue b/app/javascript/vue/global_search/groups/my_module_protocols.vue new file mode 100644 index 000000000..92205366c --- /dev/null +++ b/app/javascript/vue/global_search/groups/my_module_protocols.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/javascript/vue/global_search/groups/my_modules.vue b/app/javascript/vue/global_search/groups/my_modules.vue new file mode 100644 index 000000000..7f7e00433 --- /dev/null +++ b/app/javascript/vue/global_search/groups/my_modules.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/javascript/vue/global_search/groups/projects.vue b/app/javascript/vue/global_search/groups/projects.vue new file mode 100644 index 000000000..70a05d49b --- /dev/null +++ b/app/javascript/vue/global_search/groups/projects.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/javascript/vue/global_search/groups/protocols.vue b/app/javascript/vue/global_search/groups/protocols.vue new file mode 100644 index 000000000..e269d8a9c --- /dev/null +++ b/app/javascript/vue/global_search/groups/protocols.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/javascript/vue/global_search/groups/reports.vue b/app/javascript/vue/global_search/groups/reports.vue new file mode 100644 index 000000000..3ff1dcf88 --- /dev/null +++ b/app/javascript/vue/global_search/groups/reports.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/javascript/vue/global_search/groups/repository_rows.vue b/app/javascript/vue/global_search/groups/repository_rows.vue new file mode 100644 index 000000000..4982438cc --- /dev/null +++ b/app/javascript/vue/global_search/groups/repository_rows.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/javascript/vue/global_search/groups/results.vue b/app/javascript/vue/global_search/groups/results.vue new file mode 100644 index 000000000..0cf4c5eba --- /dev/null +++ b/app/javascript/vue/global_search/groups/results.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/javascript/vue/global_search/groups/search_mixin.js b/app/javascript/vue/global_search/groups/search_mixin.js new file mode 100644 index 000000000..fed580c6d --- /dev/null +++ b/app/javascript/vue/global_search/groups/search_mixin.js @@ -0,0 +1,140 @@ +import axios from '../../../packs/custom_axios.js'; +import StringWithEllipsis from '../../shared/string_with_ellipsis.vue'; +import SortFlyout from './helpers/sort_flyout.vue'; +import Loader from '../loader.vue'; +import ListEnd from './helpers/list_end.vue'; +import NoSearchResult from './helpers/no_search_result.vue'; +import CellTemplate from './helpers/cell_template.vue'; +import LinkTemplate from './helpers/link_template.vue'; +/* global GLOBAL_CONSTANTS I18n */ + +export default { + props: { + searchUrl: String, + query: String, + selected: Boolean, + filters: Object + }, + components: { + StringWithEllipsis, + SortFlyout, + Loader, + NoSearchResult, + ListEnd, + CellTemplate, + LinkTemplate + }, + data() { + return { + sort: 'created_desc', + results: [], + total: 0, + loading: false, + page: 1, + disabled: false, + fullDataLoaded: false, + }; + }, + watch: { + filters() { + this.reloadData(); + }, + selected() { + if (this.selected && !this.fullDataLoaded) { + this.reloadData(); + } + }, + query() { + this.reloadData(); + } + }, + mounted() { + this.loadData(); + window.addEventListener('scroll', this.handleScroll); + }, + unmounted() { + window.removeEventListener('scroll', this.handleScroll); + }, + computed: { + preparedResults() { + if (this.selected) { + return this.results; + } + return this.results.slice(0, 4); + }, + viewAll() { + return !this.selected && this.total > GLOBAL_CONSTANTS.GLOBAL_SEARCH_PREVIEW_LIMIT; + }, + loaderRows() { + return !this.selected ? 4 : 20; + }, + reachedEnd() { + return !this.page && this.selected; + }, + showNoSearchResult() { + return this.selected && !this.loading && !this.results.length; + } + }, + methods: { + labelName(object) { + if (!object) return ''; + + if (!object.archived) return object.name; + + return `${I18n.t('labels.archived')} ${object.name}`; + }, + handleScroll() { + if (this.loading || !this.selected) return; + + if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { + if (this.results.length < this.total) { + this.loadData(); + } + } + }, + changeSort(sort) { + this.sort = sort; + this.results = []; + this.page = 1; + this.loadData(); + }, + reloadData() { + if (this.query.length > 1) { + this.results = []; + this.page = 1; + this.total = 0; + this.fullDataLoaded = false; + this.loadData(); + } + }, + loadData() { + if (this.query.length < 2) return; + + if (this.loading && this.page) return; + + this.loading = true; + axios.get(this.searchUrl, { + params: { + q: this.query, + sort: this.sort, + filters: this.filters, + group: this.group, + preview: !this.selected, + page: this.page + } + }) + .then((response) => { + if (this.selected) this.fullDataLoaded = true; + this.results = this.results.concat(response.data.data); + this.total = response.data.meta.total; + this.disabled = response.data.meta.disabled; + this.loading = false; + this.page = response.data.meta.next_page; + }) + .finally(() => { + this.loading = false; + this.$emit('updated'); + }); + } + } +}; diff --git a/app/javascript/vue/global_search/loader.vue b/app/javascript/vue/global_search/loader.vue new file mode 100644 index 000000000..a11f038d6 --- /dev/null +++ b/app/javascript/vue/global_search/loader.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/javascript/vue/my_modules/modals/tags.vue b/app/javascript/vue/my_modules/modals/tags.vue index e1710acec..9a6ae0f3f 100644 --- a/app/javascript/vue/my_modules/modals/tags.vue +++ b/app/javascript/vue/my_modules/modals/tags.vue @@ -37,7 +37,7 @@
    - + - + +
    @@ -80,13 +77,20 @@
    {{ i18n.t('experiments.canvas.modal_manage_tags.create_new') }}
    -
    +
    + -