diff options
184 files changed, 5711 insertions, 1222 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index 84a8015b410..eb51a04c0ec 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -194,7 +194,7 @@ Style/EmptyLines: # Keep blank lines around access modifiers. Style/EmptyLinesAroundAccessModifier: - Enabled: false + Enabled: true # Keeps track of empty lines around block bodies. Style/EmptyLinesAroundBlockBody: @@ -771,7 +771,7 @@ Metrics/PerceivedComplexity: # Checks for ambiguous operators in the first argument of a method invocation # without parentheses. Lint/AmbiguousOperator: - Enabled: false + Enabled: true # Checks for ambiguous regexp literals in the first argument of a method # invocation without parentheses. diff --git a/CHANGELOG b/CHANGELOG index bcb7e290ede..bee1a824974 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.9.0 (unreleased) + - Bulk assign/unassign labels to issues. - Allow enabling wiki page events from Webhook management UI - Make EmailsOnPushWorker use Sidekiq mailers queue - Fix wiki page events' webhook to point to the wiki repository @@ -22,6 +23,7 @@ v 8.9.0 (unreleased) - Fix issues filter when ordering by milestone - Todos will display target state if issuable target is 'Closed' or 'Merged' - Fix bug when sorting issues by milestone due date and filtering by two or more labels + - Add support for using Yubikeys (U2F) for two-factor authentication - Link to blank group icon doesn't throw a 404 anymore - Remove 'main language' feature - Pipelines can be canceled only when there are running builds @@ -36,13 +38,16 @@ v 8.9.0 (unreleased) - Improve error handling importing projects - Remove duplicated notification settings - Put project Files and Commits tabs under Code tab - -v 8.8.4 - - Fix todos page throwing errors when you have a project pending deletion - - Reduce number of SQL queries when rendering user references + - Replace Colorize with Rainbow for coloring console output in Rake tasks. + - An indicator is now displayed at the top of the comment field for confidential issues. v 8.8.4 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds + - Fix issue with arrow keys not working in search autocomplete dropdown + - Fix todos page throwing errors when you have a project pending deletion + - Reduce number of SQL queries when rendering user references + - Upgrade to jQuery 2 + - Remove prev/next buttons on issues and merge requests v 8.8.3 - Fix 404 page when viewing TODOs that contain milestones or labels in different projects. !4312 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e952855fde1..18270d9598f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,7 @@ The designs are made using Antetype (`.atype` files). You can use the [free Antetype viewer (Mac OSX only)] or grab an exported PNG from the design (the PNG is 1:1). -The current designs can be found in the [`gitlab1.atype` file]. +The current designs can be found in the [`gitlab8.atype` file]. ### UI development kit @@ -530,4 +530,4 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [scss-styleguide]: doc/development/scss_styleguide.md "SCSS styleguide" [gitlab-design]: https://gitlab.com/gitlab-org/gitlab-design [free Antetype viewer (Mac OSX only)]: https://itunes.apple.com/us/app/antetype-viewer/id824152298?mt=12 -[`gitlab1.atype` file]: https://gitlab.com/gitlab-org/gitlab-design/tree/master/gitlab1.atype/ +[`gitlab8.atype` file]: https://gitlab.com/gitlab-org/gitlab-design/tree/master/current/ @@ -45,9 +45,10 @@ gem 'akismet', '~> 2.0' gem 'devise-two-factor', '~> 3.0.0' gem 'rqrcode-rails3', '~> 0.1.7' gem 'attr_encrypted', '~> 3.0.0' +gem 'u2f', '~> 0.2.1' # Browser detection -gem "browser", '~> 1.0.0' +gem "browser", '~> 2.0.3' # Extracting information from a git repository # Provide access to Gitlab::Git library @@ -143,7 +144,7 @@ gem 'redis-namespace' gem "httparty", '~> 0.13.3' # Colored output to console -gem "colorize", '~> 0.7.0' +gem "rainbow", '~> 2.1.0' # GitLab settings gem 'settingslogic', '~> 2.0.9' diff --git a/Gemfile.lock b/Gemfile.lock index 8ae25269e65..5f1dbd431e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,7 +92,7 @@ GEM sass (~> 3.0) slim (>= 1.3.6, < 4.0) terminal-table (~> 1.4) - browser (1.0.1) + browser (2.0.3) builder (3.2.2) bullet (5.0.0) activesupport (>= 3.0.0) @@ -747,6 +747,7 @@ GEM simple_oauth (~> 0.1.4) tzinfo (1.2.2) thread_safe (~> 0.1) + u2f (0.2.1) uglifier (2.7.2) execjs (>= 0.3.0) json (>= 1.8.0) @@ -814,7 +815,7 @@ DEPENDENCIES binding_of_caller (~> 0.7.2) bootstrap-sass (~> 3.3.0) brakeman (~> 3.2.0) - browser (~> 1.0.0) + browser (~> 2.0.3) bullet bundler-audit byebug @@ -823,7 +824,6 @@ DEPENDENCIES carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) coffee-rails (~> 4.1.0) - colorize (~> 0.7.0) connection_pool (~> 2.0) coveralls (~> 0.8.2) creole (~> 0.5.0) @@ -914,6 +914,7 @@ DEPENDENCIES rack-oauth2 (~> 1.2.1) rails (= 4.2.6) rails-deprecated_sanitizer (~> 1.0.3) + rainbow (~> 2.1.0) raphael-rails (~> 2.1.2) rblineprof rdoc (~> 3.6) @@ -963,6 +964,7 @@ DEPENDENCIES thin (~> 1.6.1) tinder (~> 1.10.0) turbolinks (~> 2.5.0) + u2f (~> 0.2.1) uglifier (~> 2.7.2) underscore-rails (~> 1.8.0) unf (~> 0.1.4) @@ -975,4 +977,4 @@ DEPENDENCIES wikicloth (= 0.8.1) BUNDLED WITH - 1.12.4 + 1.12.5 diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 18c1aa0d4e2..ebf425550e9 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -4,7 +4,7 @@ # It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the # the compiled file. # -#= require jquery +#= require jquery2 #= require jquery-ui/autocomplete #= require jquery-ui/datepicker #= require jquery-ui/draggable @@ -56,9 +56,11 @@ #= require_directory ./commit #= require_directory ./extensions #= require_directory ./lib +#= require_directory ./u2f #= require_directory . #= require fuzzaldrin-plus #= require cropper +#= require u2f window.slugify = (text) -> text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() diff --git a/app/assets/javascripts/awards_handler.coffee b/app/assets/javascripts/awards_handler.coffee index bf95e06b4e5..efa8f6cd010 100644 --- a/app/assets/javascripts/awards_handler.coffee +++ b/app/assets/javascripts/awards_handler.coffee @@ -1,201 +1,352 @@ class @AwardsHandler - constructor: (@getEmojisUrl, @postEmojiUrl, @noteableType, @noteableId, @unicodes) -> - $('.js-add-award').on 'click', (event) => - event.stopPropagation() - event.preventDefault() - @showEmojiMenu() + constructor: -> - $('html').on 'click', (event) -> - if !$(event.target).closest('.emoji-menu').length + @aliases = gl.emojiAliases() + + $(document) + .off 'click', '.js-add-award' + .on 'click', '.js-add-award', (e) => + e.stopPropagation() + e.preventDefault() + + @showEmojiMenu $(e.currentTarget) + + $('html').on 'click', (e) -> + $target = $ e.target + + unless $target.closest('.emoji-menu-content').length + $('.js-awards-block.current').removeClass 'current' + + unless $target.closest('.emoji-menu').length if $('.emoji-menu').is(':visible') + $('.js-add-award.is-active').removeClass 'is-active' $('.emoji-menu').removeClass 'is-visible' - $('.awards') - .off 'click' - .on 'click', '.js-emoji-btn', @handleClick + $(document) + .off 'click', '.js-emoji-btn' + .on 'click', '.js-emoji-btn', (e) => + e.preventDefault() - @renderFrequentlyUsedBlock() + $target = $ e.currentTarget + emoji = $target.find('.icon').data 'emoji' - handleClick: (e) -> - e.preventDefault() - emoji = $(this) - .find('.icon') - .data 'emoji' + $target.closest('.js-awards-block').addClass 'current' + @addAward @getVotesBlock(), @getAwardUrl(), emoji - if emoji is 'thumbsup' and awardsHandler.didUserClickEmoji $(this), 'thumbsdown' - awardsHandler.addAward 'thumbsdown' - else if emoji is 'thumbsdown' and awardsHandler.didUserClickEmoji $(this), 'thumbsup' - awardsHandler.addAward 'thumbsup' + showEmojiMenu: ($addBtn) -> - awardsHandler.addAward emoji + $menu = $ '.emoji-menu' - $(this).trigger 'blur' + if $addBtn.hasClass 'js-note-emoji' + $addBtn.parents('.note').find('.js-awards-block').addClass 'current' + else + $addBtn.closest('.js-awards-block').addClass 'current' - didUserClickEmoji: (that, emoji) -> - if $(that).siblings("button:has([data-emoji=#{emoji}])").attr('data-original-title') - $(that).siblings("button:has([data-emoji=#{emoji}])").attr('data-original-title').indexOf('me') > -1 + if $menu.length + $holder = $addBtn.closest('.js-award-holder') - showEmojiMenu: -> - if $('.emoji-menu').length - if $('.emoji-menu').is '.is-visible' - $('.emoji-menu').removeClass 'is-visible' + if $menu.is '.is-visible' + $addBtn.removeClass 'is-active' + $menu.removeClass 'is-visible' $('#emoji_search').blur() else - $('.emoji-menu').addClass 'is-visible' + $addBtn.addClass 'is-active' + @positionMenu($menu, $addBtn) + + $menu.addClass 'is-visible' $('#emoji_search').focus() else - $('.js-add-award').addClass 'is-loading' - $.get @getEmojisUrl, (response) => - $('.js-add-award').removeClass 'is-loading' - $('.js-award-holder').append response + $addBtn.addClass 'is-loading is-active' + url = @getAwardMenuUrl() + + @createEmojiMenu url, => + $addBtn.removeClass 'is-loading' + $menu = $('.emoji-menu') + @positionMenu($menu, $addBtn) + @renderFrequentlyUsedBlock() + setTimeout => - $('.emoji-menu').addClass 'is-visible' + $menu.addClass 'is-visible' $('#emoji_search').focus() @setupSearch() , 200 - addAward: (emoji) -> - @postEmoji emoji, => - @addAwardToEmojiBar(emoji) + + createEmojiMenu: (awardMenuUrl, callback) -> + + $.get awardMenuUrl, (response) -> + $('body').append response + callback() + + + positionMenu: ($menu, $addBtn) -> + + position = $addBtn.data('position') + + # The menu could potentially be off-screen or in a hidden overflow element + # So we position the element absolute in the body + css = + top: "#{$addBtn.offset().top + $addBtn.outerHeight()}px" + + if position? and position is 'right' + css.left = "#{($addBtn.offset().left - $menu.outerWidth()) + 20}px" + $menu.addClass 'is-aligned-right' + else + css.left = "#{$addBtn.offset().left}px" + $menu.removeClass 'is-aligned-right' + + $menu.css(css) + + + addAward: (votesBlock, awardUrl, emoji, checkMutuality = yes, callback) -> + + emoji = @normilizeEmojiName emoji + + @postEmoji awardUrl, emoji, => + @addAwardToEmojiBar votesBlock, emoji, checkMutuality + callback?() $('.emoji-menu').removeClass 'is-visible' - addAwardToEmojiBar: (emoji) -> - @addEmojiToFrequentlyUsedList(emoji) - if @exist(emoji) - if @isActive(emoji) - @decrementCounter(emoji) + addAwardToEmojiBar: (votesBlock, emoji, checkForMutuality = yes) -> + + @checkMutuality votesBlock, emoji if checkForMutuality + @addEmojiToFrequentlyUsedList emoji + + emoji = @normilizeEmojiName emoji + $emojiButton = @findEmojiIcon(votesBlock, emoji).parent() + + if $emojiButton.length > 0 + if @isActive $emojiButton + @decrementCounter $emojiButton, emoji else - counter = @findEmojiIcon(emoji).siblings('.js-counter') - counter.text(parseInt(counter.text()) + 1) - counter.parent().addClass('active') - @addMeToAuthorList(emoji) + counter = $emojiButton.find '.js-counter' + counter.text parseInt(counter.text()) + 1 + $emojiButton.addClass 'active' + @addMeToUserList votesBlock, emoji + @animateEmoji $emojiButton else - @createEmoji(emoji) - - exist: (emoji) -> - @findEmojiIcon(emoji).length > 0 - - isActive: (emoji) -> - @findEmojiIcon(emoji).parent().hasClass('active') - - decrementCounter: (emoji) -> - counter = @findEmojiIcon(emoji).siblings('.js-counter') - emojiIcon = counter.parent() - if parseInt(counter.text()) > 1 - counter.text(parseInt(counter.text()) - 1) - emojiIcon.removeClass('active') - @removeMeFromAuthorList(emoji) - else if emoji == 'thumbsup' || emoji == 'thumbsdown' - emojiIcon.tooltip('destroy') - counter.text(0) - emojiIcon.removeClass('active') - @removeMeFromAuthorList(emoji) + votesBlock.removeClass 'hidden' + @createEmoji votesBlock, emoji + + + getVotesBlock: -> + + currentBlock = $ '.js-awards-block.current' + return if currentBlock.length then currentBlock else $('.js-awards-block').eq 0 + + + getAwardUrl: -> return @getVotesBlock().data 'award-url' + + + checkMutuality: (votesBlock, emoji) -> + + awardUrl = @getAwardUrl() + + if emoji in [ 'thumbsup', 'thumbsdown' ] + mutualVote = if emoji is 'thumbsup' then 'thumbsdown' else 'thumbsup' + $emojiButton = votesBlock.find("[data-emoji=#{mutualVote}]").parent() + isAlreadyVoted = $emojiButton.hasClass 'active' + + if isAlreadyVoted + @showEmojiLoader $emojiButton + @addAward votesBlock, awardUrl, mutualVote, no, -> + $emojiButton.removeClass 'is-loading' + + + showEmojiLoader: ($emojiButton) -> + + $loader = $emojiButton.find '.fa-spinner' + + unless $loader.length + $emojiButton.append '<i class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>' + + $emojiButton.addClass 'is-loading' + + + isActive: ($emojiButton) -> $emojiButton.hasClass 'active' + + + decrementCounter: ($emojiButton, emoji) -> + + counter = $ '.js-counter', $emojiButton + counterNumber = parseInt counter.text(), 10 + + if counterNumber > 1 + counter.text counterNumber - 1 + @removeMeFromUserList $emojiButton, emoji + else if emoji is 'thumbsup' or emoji is 'thumbsdown' + $emojiButton.tooltip 'destroy' + counter.text '0' + @removeMeFromUserList $emojiButton, emoji + @removeEmoji $emojiButton if $emojiButton.parents('.note').length else - emojiIcon.tooltip('destroy') - emojiIcon.remove() - - removeMeFromAuthorList: (emoji) -> - awardBlock = @findEmojiIcon(emoji).parent() - authors = awardBlock - .attr('data-original-title') - .split(', ') - authors.splice(authors.indexOf('me'),1) + @removeEmoji $emojiButton + + $emojiButton.removeClass 'active' + + + removeEmoji: ($emojiButton) -> + + $emojiButton.tooltip('destroy') + $emojiButton.remove() + + $votesBlock = @getVotesBlock() + + if $votesBlock.find('.js-emoji-btn').length is 0 + $votesBlock.addClass 'hidden' + + + getAwardTooltip: ($awardBlock) -> + + return $awardBlock.attr('data-original-title') or $awardBlock.attr('data-title') or '' + + + removeMeFromUserList: ($emojiButton, emoji) -> + + awardBlock = $emojiButton + originalTitle = @getAwardTooltip awardBlock + + authors = originalTitle.split ', ' + authors.splice authors.indexOf('me'), 1 + + newAuthors = authors.join ', ' + awardBlock - .closest('.js-emoji-btn') - .attr('data-original-title', authors.join(', ')) - @resetTooltip(awardBlock) - - addMeToAuthorList: (emoji) -> - awardBlock = @findEmojiIcon(emoji).parent() - origTitle = awardBlock.attr('data-original-title').trim() - authors = [] + .closest '.js-emoji-btn' + .removeData 'original-title' + .attr 'data-original-title', newAuthors + + @resetTooltip awardBlock + + + addMeToUserList: (votesBlock, emoji) -> + + awardBlock = @findEmojiIcon(votesBlock, emoji).parent() + origTitle = @getAwardTooltip awardBlock + users = [] + if origTitle - authors = origTitle.split(', ') - authors.push('me') - awardBlock.attr('data-original-title', authors.join(', ')) - @resetTooltip(awardBlock) + users = origTitle.trim().split ', ' + + users.push 'me' + awardBlock.attr 'title', users.join ', ' + + @resetTooltip awardBlock + resetTooltip: (award) -> - award.tooltip('destroy') - # "destroy" call is asynchronous and there is no appropriate callback on it, this is why we need to set timeout. - setTimeout (-> - award.tooltip() - ), 200 + award.tooltip 'destroy' + + # 'destroy' call is asynchronous and there is no appropriate callback on it, this is why we need to set timeout. + cb = -> award.tooltip() + setTimeout cb, 200 + + createEmoji_: (votesBlock, emoji) -> - createEmoji: (emoji) -> - emojiCssClass = @resolveNameToCssClass(emoji) + emojiCssClass = @resolveNameToCssClass emoji + buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> + <div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div> + <span class='award-control-text js-counter'>1</span> + </button>" - nodes = [] - nodes.push( - "<button class='btn award-control js-emoji-btn has-tooltip active' data-original-title='me'>", - "<div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div>", - "<span class='award-control-text js-counter'>1</span>", - "</button>" - ) + $emojiButton = $ buttonHtml + $emojiButton + .insertBefore votesBlock.find '.js-award-holder' + .find '.emoji-icon' + .data 'emoji', emoji - $(nodes.join("\n")) - .insertBefore('.js-award-holder') - .find('.emoji-icon') - .data('emoji', emoji) + @animateEmoji $emojiButton $('.award-control').tooltip() + votesBlock.removeClass 'current' + + + animateEmoji: ($emoji) -> + + className = 'pulse animated' + + $emoji.addClass className + setTimeout (-> $emoji.removeClass className), 321 + + + createEmoji: (votesBlock, emoji) -> + + if $('.emoji-menu').length + return @createEmoji_ votesBlock, emoji + + @createEmojiMenu @getAwardMenuUrl(), => @createEmoji_ votesBlock, emoji + + + getAwardMenuUrl: -> return gl.awardMenuUrl + resolveNameToCssClass: (emoji) -> - emojiIcon = $(".emoji-menu-content [data-emoji='#{emoji}']") + + emojiIcon = $ ".emoji-menu-content [data-emoji='#{emoji}']" if emojiIcon.length > 0 - unicodeName = emojiIcon.data('unicode-name') + unicodeName = emojiIcon.data 'unicode-name' else # Find by alias - unicodeName = $(".emoji-menu-content [data-aliases*=':#{emoji}:']").data('unicode-name') + unicodeName = $(".emoji-menu-content [data-aliases*=':#{emoji}:']").data 'unicode-name' - "emoji-#{unicodeName}" + return "emoji-#{unicodeName}" - postEmoji: (emoji, callback) -> - $.post @postEmojiUrl, { note: { - note: ":#{emoji}:" - noteable_type: @noteableType - noteable_id: @noteableId - }},(data) -> - if data.ok - callback.call() - findEmojiIcon: (emoji) -> - $(".awards > .js-emoji-btn [data-emoji='#{emoji}']") + postEmoji: (awardUrl, emoji, callback) -> + + $.post awardUrl, { name: emoji }, (data) -> + callback() if data.ok + + + findEmojiIcon: (votesBlock, emoji) -> + + return votesBlock.find ".js-emoji-btn [data-emoji='#{emoji}']" + scrollToAwards: -> - $('body, html').animate({ - scrollTop: $('.awards').offset().top - 80 - }, 200) + + options = scrollTop: $('.awards').offset().top - 110 + $('body, html').animate options, 200 + + + normilizeEmojiName: (emoji) -> return @aliases[emoji] or emoji + addEmojiToFrequentlyUsedList: (emoji) -> + frequentlyUsedEmojis = @getFrequentlyUsedEmojis() - frequentlyUsedEmojis.push(emoji) - $.cookie('frequently_used_emojis', frequentlyUsedEmojis.join(','), { expires: 365 }) + frequentlyUsedEmojis.push emoji + $.cookie 'frequently_used_emojis', frequentlyUsedEmojis.join(','), { expires: 365 } + getFrequentlyUsedEmojis: -> - frequentlyUsedEmojis = ($.cookie('frequently_used_emojis') || '').split(',') - _.compact(_.uniq(frequentlyUsedEmojis)) + + frequentlyUsedEmojis = ($.cookie('frequently_used_emojis') or '').split(',') + return _.compact _.uniq frequentlyUsedEmojis + renderFrequentlyUsedBlock: -> - if $.cookie('frequently_used_emojis') + + if $.cookie 'frequently_used_emojis' frequentlyUsedEmojis = @getFrequentlyUsedEmojis() - ul = $('<ul>') + ul = $("<ul class='clearfix emoji-menu-list'>") for emoji in frequentlyUsedEmojis - do (emoji) -> - $(".emoji-menu-content [data-emoji='#{emoji}']").closest('li').clone().appendTo(ul) + $(".emoji-menu-content [data-emoji='#{emoji}']").closest('li').clone().appendTo(ul) $('input.emoji-search').after(ul).after($('<h5>').text('Frequently used')) + setupSearch: -> - $('input.emoji-search').keyup (ev) => + + $('input.emoji-search').on 'keyup', (ev) => term = $(ev.target).val() # Clean previous search results @@ -204,12 +355,14 @@ class @AwardsHandler if term # Generate a search result block h5 = $('<h5>').text('Search results').addClass('emoji-search') - foundEmojis = @searchEmojis(term).show() - ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis) + found_emojis = @searchEmojis(term).show() + ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(found_emojis) $('.emoji-menu-content ul, .emoji-menu-content h5').hide() $('.emoji-menu-content').append(h5).append(ul) else $('.emoji-menu-content').children().show() - searchEmojis: (term)-> - $(".emoji-menu-content [data-emoji*='#{term}']").closest("li").clone() + + searchEmojis: (term) -> + + $(".emoji-menu-content [data-emoji*='#{term}']").closest('li').clone() diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index a3185f87640..ec540060457 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -17,11 +17,13 @@ class Dispatcher switch page when 'projects:issues:index' Issuable.init() + new IssuableBulkActions() shortcut_handler = new ShortcutsNavigation() when 'projects:issues:show' new Issue() shortcut_handler = new ShortcutsIssuable() new ZenMode() + gl.awardsHandler = new AwardsHandler() when 'projects:milestones:show', 'groups:milestones:show', 'dashboard:milestones:show' new Milestone() when 'dashboard:todos:index' @@ -52,6 +54,7 @@ class Dispatcher new Diff() shortcut_handler = new ShortcutsIssuable(true) new ZenMode() + gl.awardsHandler = new AwardsHandler() when "projects:merge_requests:diffs" new Diff() new ZenMode() diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 3cc70185178..3d009a96d05 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -21,7 +21,7 @@ class @DueDateSelect $dropdown.glDropdown( hidden: -> $selectbox.hide() - $value.removeAttr('style') + $value.css('display', '') ) addDueDate = (isDropdown) -> @@ -42,12 +42,13 @@ class @DueDateSelect type: 'PUT' url: issueUpdateURL data: data + dataType: 'json' beforeSend: -> $loading.fadeIn() if isDropdown $dropdown.trigger('loading.gl.dropdown') $selectbox.hide() - $value.removeAttr('style') + $value.css('display', '') $valueContent.html(mediumDate) $sidebarValue.html(mediumDate) diff --git a/app/assets/javascripts/flash.js.coffee b/app/assets/javascripts/flash.js.coffee index 5de012e409f..4f73d215b85 100644 --- a/app/assets/javascripts/flash.js.coffee +++ b/app/assets/javascripts/flash.js.coffee @@ -1,5 +1,5 @@ class @Flash - constructor: (message, type)-> + constructor: (message, type = 'alert')-> @flash = $(".flash-container") @flash.html("") diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index b3f1dc969b8..7c7334e9e40 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -11,6 +11,8 @@ class GitLabDropdownFilter $inputContainer = @input.parent() $clearButton = $inputContainer.find('.js-dropdown-input-clear') + @indeterminateIds = [] + # Clear click $clearButton.on 'click', (e) => e.preventDefault() @@ -35,20 +37,20 @@ class GitLabDropdownFilter if keyCode is 13 return false - clearTimeout timeout - timeout = setTimeout => - blur_field = @shouldBlur keyCode - search_text = @input.val() + # Only filter asynchronously only if option remote is set + if @options.remote + clearTimeout timeout + timeout = setTimeout => + blur_field = @shouldBlur keyCode - if blur_field and @filterInputBlur - @input.blur() + if blur_field and @filterInputBlur + @input.blur() - if @options.remote - @options.query search_text, (data) => + @options.query @input.val(), (data) => @options.callback(data) - else - @filter search_text - , 250 + , 250 + else + @filter @input.val() shouldBlur: (keyCode) -> return BLUR_KEYCODES.indexOf(keyCode) >= 0 @@ -142,6 +144,7 @@ class GitLabDropdown LOADING_CLASS = "is-loading" PAGE_TWO_CLASS = "is-page-two" ACTIVE_CLASS = "is-active" + INDETERMINATE_CLASS = "is-indeterminate" currentIndex = -1 FILTER_INPUT = '.dropdown-input .dropdown-input-field' @@ -182,9 +185,6 @@ class GitLabDropdown @fullData = data @parseData @fullData - - if @options.filterable - @filterInput.trigger 'keyup' } # Init filterable @@ -298,6 +298,13 @@ class GitLabDropdown opened: => @addArrowKeyEvent() + if @options.setIndeterminateIds + @options.setIndeterminateIds.call(@) + + # Makes indeterminate items effective + if @fullData and @dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update') + @parseData @fullData + contentHtml = $('.dropdown-content', @dropdown).html() if @remote && contentHtml is "" @remote.execute() @@ -309,12 +316,18 @@ class GitLabDropdown hidden: (e) => @removeArrayKeyEvent() + + $input = @dropdown.find(".dropdown-input-field") + if @options.filterable - @dropdown - .find(".dropdown-input-field") + $input .blur() .val("") - .trigger("keyup") + + # Triggering 'keyup' will re-render the dropdown which is not always required + # specially if we want to keep the state of the dropdown needed for bulk-assignment + if not @options.persistWhenHide + $input.trigger("keyup") if @dropdown.find(".dropdown-toggle-page").length $('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS @@ -358,7 +371,7 @@ class GitLabDropdown if @options.renderRow # Call the render function - html = @options.renderRow(data) + html = @options.renderRow.call(@options, data, @) else if not selected value = if @options.id then @options.id(data) else data.id @@ -443,6 +456,17 @@ class GitLabDropdown $(@el).find(".dropdown-toggle-text").text @options.toggleLabel else selectedObject + else if el.hasClass(INDETERMINATE_CLASS) + el.addClass ACTIVE_CLASS + el.removeClass INDETERMINATE_CLASS + + if not value? + field.remove() + + if not field.length and fieldName + @addInput(fieldName, value) + + return selectedObject else if not @options.multiSelect or el.hasClass('dropdown-clear-active') @dropdown.find(".#{ACTIVE_CLASS}").removeClass ACTIVE_CLASS @@ -459,31 +483,42 @@ class GitLabDropdown $(@el).find(".dropdown-toggle-text").text @options.toggleLabel(selectedObject, el) if value? if !field.length and fieldName - # Create hidden input for form - input = "<input type='hidden' name='#{fieldName}' value='#{value}' />" - if @options.inputId? - input = $(input) - .attr('id', @options.inputId) - @dropdown.before input + @addInput(fieldName, value) else field.val value return selectedObject - selectRowAtIndex: (index) -> - selector = ".dropdown-content li:not(.divider):eq(#{index}) a" + addInput: (fieldName, value)-> + # Create hidden input for form + $input = $('<input>').attr('type', 'hidden') + .attr('name', fieldName) + .val(value) + + if @options.inputId? + $input.attr('id', @options.inputId) + + @dropdown.before $input + + selectRowAtIndex: (e, index) -> + selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(#{index}) a" if @dropdown.find(".dropdown-toggle-page").length selector = ".dropdown-page-one #{selector}" # simulate a click on the first link - $(selector, @dropdown).trigger "click" + $el = $(selector, @dropdown) + + if $el.length + e.preventDefault() + e.stopImmediatePropagation() + $(selector, @dropdown)[0].click() addArrowKeyEvent: -> ARROW_KEY_CODES = [38, 40] $input = @dropdown.find(".dropdown-input-field") - selector = '.dropdown-content li:not(.divider)' + selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator)' if @dropdown.find(".dropdown-toggle-page").length selector = ".dropdown-page-one #{selector}" @@ -511,8 +546,8 @@ class GitLabDropdown return false - if currentKeyCode is 13 - @selectRowAtIndex if currentIndex < 0 then 0 else currentIndex + if currentKeyCode is 13 and currentIndex isnt -1 + @selectRowAtIndex e, currentIndex removeArrayKeyEvent: -> $('body').off 'keydown' diff --git a/app/assets/javascripts/issues-bulk-assignment.js.coffee b/app/assets/javascripts/issues-bulk-assignment.js.coffee new file mode 100644 index 00000000000..16d023dd391 --- /dev/null +++ b/app/assets/javascripts/issues-bulk-assignment.js.coffee @@ -0,0 +1,109 @@ +class @IssuableBulkActions + constructor: (opts = {}) -> + # Set defaults + { + @container = $('.content') + @form = @getElement('.bulk-update') + @issues = @getElement('.issues-list .issue') + } = opts + + @bindEvents() + + getElement: (selector) -> + @container.find selector + + bindEvents: -> + @form.off('submit').on('submit', @onFormSubmit.bind(@)) + + onFormSubmit: (e) -> + e.preventDefault() + @submit() + + submit: -> + _this = @ + + xhr = $.ajax + url: @form.attr 'action' + method: @form.attr 'method' + dataType: 'JSON', + data: @getFormDataAsObject() + + xhr.done (response, status, xhr) -> + location.reload() + + xhr.fail -> + new Flash("Issue update failed") + + xhr.always @onFormSubmitAlways.bind(@) + + onFormSubmitAlways: -> + @form.find('[type="submit"]').enable() + + getSelectedIssues: -> + @issues.has('.selected_issue:checked') + + getLabelsFromSelection: -> + labels = [] + + @getSelectedIssues().map -> + _labels = $(@).data('labels') + if _labels + _labels.map (labelId) -> + labels.push(labelId) if labels.indexOf(labelId) is -1 + + labels + + ###* + * Will return only labels that were marked previously and the user has unmarked + * @return {Array} Label IDs + ### + getUnmarkedIndeterminedLabels: -> + result = [] + labelsToKeep = [] + + for el in @getElement('.labels-filter .is-indeterminate') + labelsToKeep.push $(el).data('labelId') + + for id in @getLabelsFromSelection() + # Only the ones that we are not going to keep + result.push(id) if labelsToKeep.indexOf(id) is -1 + + result + + ###* + * Simple form serialization, it will return just what we need + * Returns key/value pairs from form data + ### + getFormDataAsObject: -> + formData = + update: + state_event : @form.find('input[name="update[state_event]"]').val() + assignee_id : @form.find('input[name="update[assignee_id]"]').val() + milestone_id : @form.find('input[name="update[milestone_id]"]').val() + issues_ids : @form.find('input[name="update[issues_ids]"]').val() + add_label_ids : [] + remove_label_ids : [] + + @getLabelsToApply().map (id) -> + formData.update.add_label_ids.push id + + @getLabelsToRemove().map (id) -> + formData.update.remove_label_ids.push id + + formData + + getLabelsToApply: -> + labelIds = [] + $labels = @form.find('.labels-filter input[name="update[label_ids][]"]') + + $labels.each (k, label) -> + labelIds.push $(label).val() if label + + labelIds + + ###* + * Just an alias of @getUnmarkedIndeterminedLabels + * @return {Array} Array of labels + ### + getLabelsToRemove: -> + @getUnmarkedIndeterminedLabels() diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee index 995fd768603..ec74dfaae1a 100644 --- a/app/assets/javascripts/labels_select.js.coffee +++ b/app/assets/javascripts/labels_select.js.coffee @@ -1,5 +1,7 @@ class @LabelsSelect constructor: -> + _this = @ + $('.js-label-select').each (i, dropdown) -> $dropdown = $(dropdown) projectId = $dropdown.data('project-id') @@ -196,10 +198,18 @@ class @LabelsSelect callback data - renderRow: (label) -> - removesAll = label.id is 0 or not label.id? + renderRow: (label, instance) -> + $li = $('<li>') + $a = $('<a href="#">') selectedClass = [] + removesAll = label.id is 0 or not label.id? + + if $dropdown.hasClass('js-filter-bulk-update') + indeterminate = instance.indeterminateIds + if indeterminate.indexOf(label.id) isnt -1 + selectedClass.push 'is-indeterminate' + if $form.find("input[type='hidden']\ [name='#{$dropdown.data('fieldName')}']\ [value='#{this.id(label)}']").length @@ -230,13 +240,17 @@ class @LabelsSelect else colorEl = '' - "<li> - <a href='#' class='#{selectedClass.join(' ')}'> - #{colorEl} - #{_.escape(label.title)} - </a> - </li>" - filterable: true + # We need to identify which items are actually labels + if label.id + selectedClass.push('label-item') + $a.attr('data-label-id', label.id) + + $a.addClass(selectedClass.join(' ')) + .html("#{colorEl} #{_.escape(label.title)}") + + # Return generated html + $li.html($a).prop('outerHTML') + persistWhenHide: $dropdown.data('persistWhenHide') search: fields: ['title'] selectable: true @@ -280,10 +294,19 @@ class @LabelsSelect else if $dropdown.hasClass('js-filter-submit') $dropdown.closest('form').submit() else - saveLabelData() + if not $dropdown.hasClass 'js-filter-bulk-update' + saveLabelData() + + if $dropdown.hasClass('js-filter-bulk-update') + # If we are persisting state we need the classes + if not @options.persistWhenHide + $dropdown.parent().find('.is-active, .is-indeterminate').removeClass() multiSelect: $dropdown.hasClass 'js-multiselect' clicked: (label) -> + if $dropdown.hasClass('js-filter-bulk-update') + return + page = $('body').data 'page' isIssueIndex = page is 'projects:issues:index' isMRIndex = page is 'projects:merge_requests:index' @@ -298,4 +321,31 @@ class @LabelsSelect return else saveLabelData() + + setIndeterminateIds: -> + if @dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update') + @indeterminateIds = _this.getIndeterminateIds() ) + + @bindEvents() + + bindEvents: -> + $('body').on 'change', '.selected_issue', @onSelectCheckboxIssue + + onSelectCheckboxIssue: -> + return if $('.selected_issue:checked').length + + # Remove inputs + $('.issues_bulk_update .labels-filter input[type="hidden"]').remove() + + # Also restore button text + $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label') + + getIndeterminateIds: -> + label_ids = [] + + $('.selected_issue:checked').each (i, el) -> + issue_id = $(el).data('id') + label_ids.push $("#issue_#{issue_id}").data('labels') + + _.flatten(label_ids) diff --git a/app/assets/javascripts/lib/emoji_aliases.js.coffee.erb b/app/assets/javascripts/lib/emoji_aliases.js.coffee.erb new file mode 100644 index 00000000000..80f9936b9c2 --- /dev/null +++ b/app/assets/javascripts/lib/emoji_aliases.js.coffee.erb @@ -0,0 +1,2 @@ +gl.emojiAliases = -> + JSON.parse('<%= Gitlab::AwardEmoji.aliases.to_json %>') diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee index 345a0e447af..1d061d5edb7 100644 --- a/app/assets/javascripts/milestone_select.js.coffee +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -83,7 +83,7 @@ class @MilestoneSelect $selectbox.hide() # display:block overrides the hide-collapse rule - $value.removeAttr('style') + $value.css('display', '') clicked: (selected) -> page = $('body').data 'page' isIssueIndex = page is 'projects:issues:index' @@ -118,7 +118,7 @@ class @MilestoneSelect $dropdown.trigger('loaded.gl.dropdown') $loading.fadeOut() $selectbox.hide() - $value.removeAttr('style') + $value.css('display', '') if data.milestone? data.milestone.namespace = _this.currentProject.namespace data.milestone.path = _this.currentProject.path diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index f8151963fa7..8e33e915ba5 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -162,13 +162,14 @@ class @Notes renderNote: (note) -> unless note.valid if note.award - flash = new Flash('You have already used this award emoji!', 'alert') + flash = new Flash('You have already awarded this emoji!', 'alert') flash.pinTo('.header-content') return if note.award - awardsHandler.addAwardToEmojiBar(note.note) - awardsHandler.scrollToAwards() + votesBlock = $('.js-awards-block').eq 0 + gl.awardsHandler.addAwardToEmojiBar votesBlock, note.name + gl.awardsHandler.scrollToAwards() # render note if it not present in loaded list # or skip if rendered diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 2122e80f57a..5eb915a51ea 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -156,11 +156,14 @@ class @SearchAutocomplete # No need to enable anything if user is not logged in return if !gon.current_user_id - _this = @ - @loadingSuggestions = false + unless @dropdown.hasClass('open') + _this = @ + @loadingSuggestions = false - @dropdown.addClass('open') - @searchInput.removeClass('disabled') + @dropdown + .addClass('open') + .trigger('shown.bs.dropdown') + @searchInput.removeClass('disabled') onSearchInputKeyDown: => # Saves last length of the entered text @@ -191,7 +194,7 @@ class @SearchAutocomplete @disableAutocomplete() else # We should display the menu only when input is not empty - @enableAutocomplete() + @enableAutocomplete() if e.keyCode isnt KEYCODE.ENTER @wrap.toggleClass 'has-value', !!e.target.value diff --git a/app/assets/javascripts/shortcuts_issuable.coffee b/app/assets/javascripts/shortcuts_issuable.coffee index ccb42ab2168..c93bcf3ceec 100644 --- a/app/assets/javascripts/shortcuts_issuable.coffee +++ b/app/assets/javascripts/shortcuts_issuable.coffee @@ -10,14 +10,6 @@ class @ShortcutsIssuable extends ShortcutsNavigation @replyWithSelectedText() return false ) - Mousetrap.bind('j', => - @prevIssue() - return false - ) - Mousetrap.bind('k', => - @nextIssue() - return false - ) Mousetrap.bind('e', => @editIssue() return false @@ -29,16 +21,6 @@ class @ShortcutsIssuable extends ShortcutsNavigation else @enabledHelp.push('.hidden-shortcut.issues') - prevIssue: -> - $prevBtn = $('.prev-btn') - if not $prevBtn.hasClass('disabled') - Turbolinks.visit($prevBtn.attr('href')) - - nextIssue: -> - $nextBtn = $('.next-btn') - if not $nextBtn.hasClass('disabled') - Turbolinks.visit($nextBtn.attr('href')) - replyWithSelectedText: -> if window.getSelection selected = window.getSelection().toString() diff --git a/app/assets/javascripts/u2f/authenticate.js.coffee b/app/assets/javascripts/u2f/authenticate.js.coffee new file mode 100644 index 00000000000..6deb902c8de --- /dev/null +++ b/app/assets/javascripts/u2f/authenticate.js.coffee @@ -0,0 +1,63 @@ +# Authenticate U2F (universal 2nd factor) devices for users to authenticate with. +# +# State Flow #1: setup -> in_progress -> authenticated -> POST to server +# State Flow #2: setup -> in_progress -> error -> setup + +class @U2FAuthenticate + constructor: (@container, u2fParams) -> + @appId = u2fParams.app_id + @challenges = u2fParams.challenges + @signRequests = u2fParams.sign_requests + + start: () => + if U2FUtil.isU2FSupported() + @renderSetup() + else + @renderNotSupported() + + authenticate: () => + u2f.sign(@appId, @challenges, @signRequests, (response) => + if response.errorCode + error = new U2FError(response.errorCode) + @renderError(error); + else + @renderAuthenticated(JSON.stringify(response)) + , 10) + + ############# + # Rendering # + ############# + + templates: { + "notSupported": "#js-authenticate-u2f-not-supported", + "setup": '#js-authenticate-u2f-setup', + "inProgress": '#js-authenticate-u2f-in-progress', + "error": '#js-authenticate-u2f-error', + "authenticated": '#js-authenticate-u2f-authenticated' + } + + renderTemplate: (name, params) => + templateString = $(@templates[name]).html() + template = _.template(templateString) + @container.html(template(params)) + + renderSetup: () => + @renderTemplate('setup') + @container.find('#js-login-u2f-device').on('click', @renderInProgress) + + renderInProgress: () => + @renderTemplate('inProgress') + @authenticate() + + renderError: (error) => + @renderTemplate('error', {error_message: error.message()}) + @container.find('#js-u2f-try-again').on('click', @renderSetup) + + renderAuthenticated: (deviceResponse) => + @renderTemplate('authenticated') + # Prefer to do this instead of interpolating using Underscore templates + # because of JSON escaping issues. + @container.find("#js-device-response").val(deviceResponse) + + renderNotSupported: () => + @renderTemplate('notSupported') diff --git a/app/assets/javascripts/u2f/error.js.coffee b/app/assets/javascripts/u2f/error.js.coffee new file mode 100644 index 00000000000..1a2fc3e757f --- /dev/null +++ b/app/assets/javascripts/u2f/error.js.coffee @@ -0,0 +1,13 @@ +class @U2FError + constructor: (@errorCode) -> + @httpsDisabled = (window.location.protocol isnt 'https:') + console.error("U2F Error Code: #{@errorCode}") + + message: () => + switch + when (@errorCode is u2f.ErrorCodes.BAD_REQUEST and @httpsDisabled) + "U2F only works with HTTPS-enabled websites. Contact your administrator for more details." + when @errorCode is u2f.ErrorCodes.DEVICE_INELIGIBLE + "This device has already been registered with us." + else + "There was a problem communicating with your device." diff --git a/app/assets/javascripts/u2f/register.js.coffee b/app/assets/javascripts/u2f/register.js.coffee new file mode 100644 index 00000000000..74472cfa120 --- /dev/null +++ b/app/assets/javascripts/u2f/register.js.coffee @@ -0,0 +1,63 @@ +# Register U2F (universal 2nd factor) devices for users to authenticate with. +# +# State Flow #1: setup -> in_progress -> registered -> POST to server +# State Flow #2: setup -> in_progress -> error -> setup + +class @U2FRegister + constructor: (@container, u2fParams) -> + @appId = u2fParams.app_id + @registerRequests = u2fParams.register_requests + @signRequests = u2fParams.sign_requests + + start: () => + if U2FUtil.isU2FSupported() + @renderSetup() + else + @renderNotSupported() + + register: () => + u2f.register(@appId, @registerRequests, @signRequests, (response) => + if response.errorCode + error = new U2FError(response.errorCode) + @renderError(error); + else + @renderRegistered(JSON.stringify(response)) + , 10) + + ############# + # Rendering # + ############# + + templates: { + "notSupported": "#js-register-u2f-not-supported", + "setup": '#js-register-u2f-setup', + "inProgress": '#js-register-u2f-in-progress', + "error": '#js-register-u2f-error', + "registered": '#js-register-u2f-registered' + } + + renderTemplate: (name, params) => + templateString = $(@templates[name]).html() + template = _.template(templateString) + @container.html(template(params)) + + renderSetup: () => + @renderTemplate('setup') + @container.find('#js-setup-u2f-device').on('click', @renderInProgress) + + renderInProgress: () => + @renderTemplate('inProgress') + @register() + + renderError: (error) => + @renderTemplate('error', {error_message: error.message()}) + @container.find('#js-u2f-try-again').on('click', @renderSetup) + + renderRegistered: (deviceResponse) => + @renderTemplate('registered') + # Prefer to do this instead of interpolating using Underscore templates + # because of JSON escaping issues. + @container.find("#js-device-response").val(deviceResponse) + + renderNotSupported: () => + @renderTemplate('notSupported') diff --git a/app/assets/javascripts/u2f/util.js.coffee.erb b/app/assets/javascripts/u2f/util.js.coffee.erb new file mode 100644 index 00000000000..d59341c38b9 --- /dev/null +++ b/app/assets/javascripts/u2f/util.js.coffee.erb @@ -0,0 +1,15 @@ +# Helper class for U2F (universal 2nd factor) device registration and authentication. + +class @U2FUtil + @isU2FSupported: -> + if @testMode + true + else + gon.u2f.browser_supports_u2f + + @enableTestMode: -> + @testMode = true + +<% if Rails.env.test? %> +U2FUtil.enableTestMode(); +<% end %> diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index 519618aa617..de0eae58bff 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -149,7 +149,7 @@ class @UsersSelect hidden: (e) -> $selectbox.hide() # display:block overrides the hide-collapse rule - $value.removeAttr('style') + $value.css('display', '') clicked: (user) -> page = $('body').data 'page' diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 93c63c69843..28634d0c59f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -232,9 +232,8 @@ a { padding-left: 25px; - &.is-active { + &.is-indeterminate, &.is-active { &::before { - content: "\f00c"; position: absolute; left: 5px; top: 50%; @@ -246,6 +245,14 @@ -moz-osx-font-smoothing: grayscale; } } + + &.is-indeterminate::before { + content: "\f068"; + } + + &.is-active::before { + content: "\f00c"; + } } } diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 250d6309291..828e7224231 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -2,18 +2,10 @@ * Generic mixins */ @mixin box-shadow($shadow) { - -webkit-box-shadow: $shadow; - -moz-box-shadow: $shadow; - -ms-box-shadow: $shadow; - -o-box-shadow: $shadow; box-shadow: $shadow; } @mixin border-radius($radius) { - -webkit-border-radius: $radius; - -moz-border-radius: $radius; - -ms-border-radius: $radius; - -o-border-radius: $radius; border-radius: $radius; } diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index bd531f8376b..d4e5cc819a4 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -66,10 +66,6 @@ display: none; } - %ul.notes .note-role, .note-actions { - display: none; - } - .nav-links, .nav-links { li a { font-size: 14px; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 29501069d27..0b0bd80c326 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -5,7 +5,7 @@ padding: 0; .timeline-entry { - padding: $gl-padding $gl-btn-padding; + padding: $gl-padding $gl-btn-padding 11px; border-color: $table-border-color; color: $gl-gray; border-bottom: 1px solid $border-white-light; diff --git a/app/assets/stylesheets/pages/awards.scss b/app/assets/stylesheets/pages/awards.scss index 37bf38fa65d..05d1ee5b998 100644 --- a/app/assets/stylesheets/pages/awards.scss +++ b/app/assets/stylesheets/pages/awards.scss @@ -1,6 +1,4 @@ .awards { - line-height: 34px; - .emoji-icon { width: 20px; height: 20px; @@ -9,8 +7,6 @@ .emoji-menu { position: absolute; - top: 100%; - left: 0; margin-top: 3px; z-index: 1000; min-width: 160px; @@ -23,7 +19,12 @@ opacity: 0; transform: scale(.2); transform-origin: 0 -45px; - transition: all .3s cubic-bezier(.87,-.41,.19,1.44); + transition: .3s cubic-bezier(.87,-.41,.19,1.44); + transition-property: transform, opacity; + + &.is-aligned-right { + transform-origin: 100% -45px; + } &.is-visible { pointer-events: all; @@ -94,6 +95,7 @@ .award-control { margin-right: 5px; + margin-bottom: 5px; padding-left: 5px; padding-right: 5px; line-height: 20px; @@ -107,7 +109,8 @@ } &.is-loading { - .award-control-icon { + .award-control-icon-normal, + .emoji-icon { display: none; } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index aa41565f812..44222e8e8a4 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -3,12 +3,7 @@ background: #111; color: #fff; font-family: $monospace_font; - white-space: pre; - white-space: pre-wrap; /* css-3 */ - white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ - white-space: -pre-wrap; /* Opera 4-6 */ - white-space: -o-pre-wrap; /* Opera 7 */ - word-wrap: break-word; /* Internet Explorer 5.5+ */ + white-space: pre-wrap; overflow: auto; overflow-y: hidden; font-size: 12px; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 7fa13e66b43..a6765fbc7c7 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -87,6 +87,39 @@ } } +.md-header .nav-links { + display: flex; + display: -webkit-flex; + flex-flow: row wrap; + -webkit-flex-flow: row wrap; + width: 100%; + + .pull-right { + // Flexbox quirk to make sure right-aligned items stay right-aligned. + margin-left: auto; + } +} + +.confidential-issue-warning { + background-color: $gray-normal; + border-radius: 3px; + padding: 3px 12px; + margin: auto; + margin-top: 0; + text-align: center; + font-size: 13px; + + @media (max-width: $screen-md-min) { + // On smaller devices the warning becomes the fourth item in the list, + // rather than centering, and grows to span the full width of the + // comment area. + order: 4; + -webkit-order: 4; + margin: 6px auto; + width: 100%; + } +} + .discussion-form { padding: $gl-padding-top $gl-padding; background-color: $white-light; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index a3e1ac13a43..0c084118753 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -69,6 +69,10 @@ ul.notes { .note-edit-form { display: block; + + &.current-note-edit-form + .note-awards { + display: none; + } } } @@ -116,8 +120,41 @@ ul.notes { } } + .note-awards { + .js-awards-block { + padding: 2px; + margin-top: 10px; + } + + .award-control { + font-size: 13px; + padding: 2px 5px; + } + } + .note-header { padding-bottom: 3px; + padding-right: 20px; + + @media (min-width: $screen-sm-min) { + padding-right: 0; + } + } + + .note-emoji-button { + .fa-spinner { + display: none; + } + + &.is-loading { + .fa-smile-o { + display: none; + } + + .fa-spinner { + display: inline-block; + } + } } } @@ -179,6 +216,8 @@ ul.notes { .discussion-header, .note-header { + position: relative; + a { color: inherit; @@ -215,6 +254,16 @@ ul.notes { color: $notes-action-color; } +.note-actions { + position: absolute; + right: 0; + top: 0; + + @media (min-width: $screen-sm-min) { + position: relative; + } +} + .discussion-actions { @media (max-width: $screen-md-max) { float: none; @@ -228,8 +277,13 @@ ul.notes { .note-action-button { display: inline-block; - margin-left: 10px; - line-height: 24px; + margin-left: 0; + line-height: 20px; + + @media (min-width: $screen-sm-min) { + margin-left: 10px; + line-height: 24px; + } .fa { color: $notes-action-color; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 037ad520545..ae524cd6bae 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -158,13 +158,11 @@ .search-holder { @media (min-width: $screen-sm-min) { display: -webkit-flex; - display: -ms-flexbox; display: flex; } .search-field-holder { -webkit-flex: 1 0 auto; - -ms-flex: 1 0 auto; flex: 1 0 auto; position: relative; margin-right: 0; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c28d1ca9e3b..62f63701799 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -182,8 +182,8 @@ class ApplicationController < ActionController::Base end def check_2fa_requirement - if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled && !skip_two_factor? - redirect_to new_profile_two_factor_auth_path + if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor? + redirect_to profile_two_factor_auth_path end end @@ -342,6 +342,10 @@ class ApplicationController < ActionController::Base session[:skip_tfa] && session[:skip_tfa] > Time.current end + def browser_supports_u2f? + browser.chrome? && browser.version.to_i >= 41 && !browser.device.mobile? + end + def redirect_to_home_page_url? # If user is not signed-in and tries to access root_path - redirect him to landing page # Don't redirect to the default URL to prevent endless redirections @@ -355,6 +359,13 @@ class ApplicationController < ActionController::Base current_user.nil? && root_path == request.path end + # U2F (universal 2nd factor) devices need a unique identifier for the application + # to perform authentication. + # https://developers.yubico.com/U2F/App_ID.html + def u2f_app_id + request.base_url + end + private def set_default_sort diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index d5918a7af3b..998b8adc411 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -24,7 +24,64 @@ module AuthenticatesWithTwoFactor # Returns nil def prompt_for_two_factor(user) session[:otp_user_id] = user.id + setup_u2f_authentication(user) + render 'devise/sessions/two_factor' + end + + def authenticate_with_two_factor + user = self.resource = find_user + + if user_params[:otp_attempt].present? && session[:otp_user_id] + authenticate_with_two_factor_via_otp(user) + elsif user_params[:device_response].present? && session[:otp_user_id] + authenticate_with_two_factor_via_u2f(user) + elsif user && user.valid_password?(user_params[:password]) + prompt_for_two_factor(user) + end + end + + private + + def authenticate_with_two_factor_via_otp(user) + if valid_otp_attempt?(user) + # Remove any lingering user data from login + session.delete(:otp_user_id) + + remember_me(user) if user_params[:remember_me] == '1' + sign_in(user) + else + flash.now[:alert] = 'Invalid two-factor code.' + render :two_factor + end + end + + # Authenticate using the response from a U2F (universal 2nd factor) device + def authenticate_with_two_factor_via_u2f(user) + if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenges]) + # Remove any lingering user data from login + session.delete(:otp_user_id) + session.delete(:challenges) + + sign_in(user) + else + flash.now[:alert] = 'Authentication via U2F device failed.' + prompt_for_two_factor(user) + end + end + + # Setup in preparation of communication with a U2F (universal 2nd factor) device + # Actual communication is performed using a Javascript API + def setup_u2f_authentication(user) + key_handles = user.u2f_registrations.pluck(:key_handle) + u2f = U2F::U2F.new(u2f_app_id) - render 'devise/sessions/two_factor' and return + if key_handles.present? + sign_requests = u2f.authentication_requests(key_handles) + challenges = sign_requests.map(&:challenge) + session[:challenges] = challenges + gon.push(u2f: { challenges: challenges, app_id: u2f_app_id, + sign_requests: sign_requests, + browser_supports_u2f: browser_supports_u2f? }) + end end end diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb new file mode 100644 index 00000000000..036777c80c1 --- /dev/null +++ b/app/controllers/concerns/toggle_award_emoji.rb @@ -0,0 +1,31 @@ +module ToggleAwardEmoji + extend ActiveSupport::Concern + + included do + before_action :authenticate_user!, only: [:toggle_award_emoji] + end + + def toggle_award_emoji + name = params.require(:name) + + awardable.toggle_award_emoji(name, current_user) + TodoService.new.new_award_emoji(to_todoable(awardable), current_user) + + render json: { ok: true } + end + + private + + def to_todoable(awardable) + case awardable + when Note + awardable.noteable + else + awardable + end + end + + def awardable + raise NotImplementedError + end +end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 8f83fdd02bc..6a358fdcc05 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -1,7 +1,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController skip_before_action :check_2fa_requirement - def new + def show unless current_user.otp_secret current_user.otp_secret = User.generate_otp_secret(32) end @@ -12,21 +12,22 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController current_user.save! if current_user.changed? - if two_factor_authentication_required? + if two_factor_authentication_required? && !current_user.two_factor_enabled? if two_factor_grace_period_expired? - flash.now[:alert] = 'You must enable Two-factor Authentication for your account.' + flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.' else grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours - flash.now[:alert] = "You must enable Two-factor Authentication for your account before #{l(grace_period_deadline)}." + flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}." end end @qr_code = build_qr_code + setup_u2f_registration end def create if current_user.validate_and_consume_otp!(params[:pin_code]) - current_user.two_factor_enabled = true + current_user.otp_required_for_login = true @codes = current_user.generate_otp_backup_codes! current_user.save! @@ -34,8 +35,23 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController else @error = 'Invalid pin code' @qr_code = build_qr_code + setup_u2f_registration + render 'show' + end + end + + # A U2F (universal 2nd factor) device's information is stored after successful + # registration, which is then used while 2FA authentication is taking place. + def create_u2f + @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, params[:device_response], session[:challenges]) - render 'new' + if @u2f_registration.persisted? + session.delete(:challenges) + redirect_to profile_account_path, notice: "Your U2F device was registered!" + else + @qr_code = build_qr_code + setup_u2f_registration + render :show end end @@ -70,4 +86,21 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController def issuer_host Gitlab.config.gitlab.host end + + # Setup in preparation of communication with a U2F (universal 2nd factor) device + # Actual communication is performed using a Javascript API + def setup_u2f_registration + @u2f_registration ||= U2fRegistration.new + @registration_key_handles = current_user.u2f_registrations.pluck(:key_handle) + u2f = U2F::U2F.new(u2f_app_id) + + registration_requests = u2f.registration_requests + sign_requests = u2f.authentication_requests(@registration_key_handles) + session[:challenges] = registration_requests.map(&:challenge) + + gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id, + register_requests: registration_requests, + sign_requests: sign_requests, + browser_supports_u2f: browser_supports_u2f? }) + end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index cfea1266516..832d7deb57d 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -37,7 +37,7 @@ class Projects::ArtifactsController < Projects::ApplicationController private def build - @build ||= project.builds.unscoped.find_by!(id: params[:build_id]) + @build ||= project.builds.find_by!(id: params[:build_id]) end def artifacts_file diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index bb1f6c5e980..db3ae586059 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -81,7 +81,7 @@ class Projects::BuildsController < Projects::ApplicationController private def build - @build ||= project.builds.unscoped.find_by!(id: params[:id]) + @build ||= project.builds.find_by!(id: params[:id]) end def build_path(build) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 016f5dd0005..4e2d3bebb2e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,6 +1,7 @@ class Projects::IssuesController < Projects::ApplicationController include ToggleSubscriptionAction include IssuableActions + include ToggleAwardEmoji before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, @@ -62,7 +63,7 @@ class Projects::IssuesController < Projects::ApplicationController def show @note = @project.notes.new(noteable: @issue) - @notes = @issue.notes.nonawards.with_associations.fresh + @notes = @issue.notes.with_associations.fresh @noteable = @issue respond_to do |format| @@ -155,7 +156,12 @@ class Projects::IssuesController < Projects::ApplicationController def bulk_update result = Issues::BulkUpdateService.new(project, current_user, bulk_update_params).execute - redirect_back_or_default(default: { action: 'index' }, options: { notice: "#{result[:count]} issues updated" }) + + respond_to do |format| + format.json do + render json: { notice: "#{result[:count]} issues updated" } + end + end end protected @@ -169,6 +175,7 @@ class Projects::IssuesController < Projects::ApplicationController end alias_method :subscribable_resource, :issue alias_method :issuable, :issue + alias_method :awardable, :issue def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) @@ -214,7 +221,10 @@ class Projects::IssuesController < Projects::ApplicationController :issues_ids, :assignee_id, :milestone_id, - :state_event + :state_event, + label_ids: [], + add_label_ids: [], + remove_label_ids: [] ) end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 3142fe5c767..f78b429b3e7 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -2,6 +2,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController include ToggleSubscriptionAction include DiffHelper include IssuableActions + include ToggleAwardEmoji before_action :module_enabled before_action :merge_request, only: [ @@ -201,7 +202,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController if params[:merge_when_build_succeeds].present? && @merge_request.ci_commit && @merge_request.ci_commit.active? MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) - .execute(@merge_request) + .execute(@merge_request) @status = :merge_when_build_succeeds else MergeWorker.perform_async(@merge_request.id, current_user.id, params) @@ -270,6 +271,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end alias_method :subscribable_resource, :merge_request alias_method :issuable, :merge_request + alias_method :awardable, :merge_request def closes_issues @closes_issues ||= @merge_request.closes_issues @@ -305,7 +307,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController def define_show_vars # Build a note object for comment form @note = @project.notes.new(noteable: @merge_request) - @notes = @merge_request.mr_and_commit_notes.nonawards.inc_author.fresh + @notes = @merge_request.mr_and_commit_notes.inc_author.fresh @discussions = @notes.discussions @noteable = @merge_request diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 40b24d550e0..836f79ff080 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,9 +1,11 @@ class Projects::NotesController < Projects::ApplicationController + include ToggleAwardEmoji + # Authorize before_action :authorize_read_note! before_action :authorize_create_note!, only: [:create] before_action :authorize_admin_note!, only: [:update, :destroy] - before_action :find_current_user_notes, except: [:destroy, :delete_attachment, :award_toggle] + before_action :find_current_user_notes, only: [:index] def index current_fetched_at = Time.now.to_i @@ -56,35 +58,12 @@ class Projects::NotesController < Projects::ApplicationController end end - def award_toggle - noteable = if note_params[:noteable_type] == "issue" - project.issues.find(note_params[:noteable_id]) - else - project.merge_requests.find(note_params[:noteable_id]) - end - - data = { - author: current_user, - is_award: true, - note: note_params[:note].delete(":") - } - - note = noteable.notes.find_by(data) - - if note - note.destroy - else - Notes::CreateService.new(project, current_user, note_params).execute - end - - render json: { ok: true } - end - private def note @note ||= @project.notes.find(params[:id]) end + alias_method :awardable, :note def note_to_html(note) render_to_string( @@ -131,13 +110,20 @@ class Projects::NotesController < Projects::ApplicationController end def note_json(note) - if note.valid? + if note.is_a?(AwardEmoji) + { + valid: note.valid?, + award: true, + id: note.id, + name: note.name + } + elsif note.valid? { valid: true, id: note.id, discussion_id: note.discussion_id, html: note_to_html(note), - award: note.is_award, + award: false, note: note.note, discussion_html: note_to_discussion_html(note), discussion_with_diff_html: note_to_discussion_with_diff_html(note) @@ -145,7 +131,7 @@ class Projects::NotesController < Projects::ApplicationController else { valid: false, - award: note.is_award, + award: false, errors: note.errors } end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index f94e2a84fa2..3af62c7696c 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -139,7 +139,7 @@ class ProjectsController < Projects::ApplicationController participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) @suggestions = { - emojis: AwardEmoji.urls, + emojis: Gitlab::AwardEmoji.urls, issues: autocomplete.issues, milestones: autocomplete.milestones, mergerequests: autocomplete.merge_requests, diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d68c2a708e3..f6eedb1773c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -30,8 +30,7 @@ class SessionsController < Devise::SessionsController resource.update_attributes(reset_password_token: nil, reset_password_sent_at: nil) end - authenticated_with = user_params[:otp_attempt] ? "two-factor" : "standard" - log_audit_event(current_user, with: authenticated_with) + log_audit_event(current_user, with: authentication_method) end end @@ -54,7 +53,7 @@ class SessionsController < Devise::SessionsController end def user_params - params.require(:user).permit(:login, :password, :remember_me, :otp_attempt) + params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response) end def find_user @@ -89,27 +88,6 @@ class SessionsController < Devise::SessionsController find_user.try(:two_factor_enabled?) end - def authenticate_with_two_factor - user = self.resource = find_user - - if user_params[:otp_attempt].present? && session[:otp_user_id] - if valid_otp_attempt?(user) - # Remove any lingering user data from login - session.delete(:otp_user_id) - - remember_me(user) if user_params[:remember_me] == '1' - sign_in(user) and return - else - flash.now[:alert] = 'Invalid two-factor code.' - render :two_factor and return - end - else - if user && user.valid_password?(user_params[:password]) - prompt_for_two_factor(user) - end - end - end - def auto_sign_in_with_provider provider = Gitlab.config.omniauth.auto_sign_in_with_provider return unless provider.present? @@ -138,4 +116,14 @@ class SessionsController < Devise::SessionsController def load_recaptcha Gitlab::Recaptcha.load_configurations! end + + def authentication_method + if user_params[:otp_attempt] + "two-factor" + elsif user_params[:device_response] + "two-factor-via-u2f-device" + else + "standard" + end + end end diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index c41be333537..ee14ac60fb4 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -12,9 +12,9 @@ class NotesFinder when "commit" project.notes.for_commit_id(target_id).non_diff_notes when "issue" - project.issues.find(target_id).notes.nonawards.inc_author + project.issues.find(target_id).notes.inc_author when "merge_request" - project.merge_requests.find(target_id).mr_and_commit_notes.nonawards.inc_author + project.merge_requests.find(target_id).mr_and_commit_notes.inc_author when "snippet", "project_snippet" project.snippets.find(target_id).notes else diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index b05fa0a14d6..cd4d778e508 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -66,7 +66,7 @@ module AuthHelper def two_factor_skippable? current_application_settings.require_two_factor_authentication && - !current_user.two_factor_enabled && + !current_user.two_factor_enabled? && current_application_settings.two_factor_grace_period && !two_factor_grace_period_expired? end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index fe84ee3de44..40d8ce8a1d3 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -8,14 +8,6 @@ module IssuablesHelper "right-sidebar-#{sidebar_gutter_collapsed? ? 'collapsed' : 'expanded'}" end - def issuables_count(issuable) - base_issuable_scope(issuable).maximum(:iid) - end - - def next_issuable_for(issuable) - base_issuable_scope(issuable).where('iid > ?', issuable.iid).last - end - def multi_label_name(current_labels, default_label) # current_labels may be a string from before if current_labels.is_a?(Array) @@ -45,10 +37,6 @@ module IssuablesHelper end end - def prev_issuable_for(issuable) - base_issuable_scope(issuable).where('iid < ?', issuable.iid).first - end - def user_dropdown_label(user_id, default_label) return default_label if user_id.nil? return "Unassigned" if user_id == "0" @@ -96,5 +84,4 @@ module IssuablesHelper issuable.open? ? :opened : :closed end end - end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 173bdbb8654..72bd1fbbd81 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -145,16 +145,14 @@ module IssuesHelper end end - def emoji_author_list(notes, current_user) - list = notes.map do |note| - note.author == current_user ? "me" : note.author.name - end - - list.join(", ") + def award_user_list(awards, current_user) + awards.map do |award| + award.user == current_user ? 'me' : award.user.name + end.join(', ') end - def note_active_class(notes, current_user) - if current_user && notes.pluck(:author_id).include?(current_user.id) + def award_active_class(awards, current_user) + if current_user && awards.find { |a| a.user_id == current_user.id } "active" else "" diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb new file mode 100644 index 00000000000..59c7d87f5df --- /dev/null +++ b/app/models/award_emoji.rb @@ -0,0 +1,26 @@ +class AwardEmoji < ActiveRecord::Base + DOWNVOTE_NAME = "thumbsdown".freeze + UPVOTE_NAME = "thumbsup".freeze + + include Participable + + belongs_to :awardable, polymorphic: true + belongs_to :user + + validates :awardable, :user, presence: true + validates :name, presence: true, inclusion: { in: Emoji.emojis_names } + validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] } + + participant :user + + scope :downvotes, -> { where(name: DOWNVOTE_NAME) } + scope :upvotes, -> { where(name: UPVOTE_NAME) } + + def downvote? + self.name == DOWNVOTE_NAME + end + + def upvote? + self.name == UPVOTE_NAME + end +end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb new file mode 100644 index 00000000000..aa4b4201250 --- /dev/null +++ b/app/models/concerns/awardable.rb @@ -0,0 +1,81 @@ +module Awardable + extend ActiveSupport::Concern + + included do + has_many :award_emoji, as: :awardable, dependent: :destroy + + if self < Participable + participant :award_emoji + end + end + + module ClassMethods + def order_upvotes_desc + order_votes_desc(AwardEmoji::UPVOTE_NAME) + end + + def order_downvotes_desc + order_votes_desc(AwardEmoji::DOWNVOTE_NAME) + end + + def order_votes_desc(emoji_name) + awardable_table = self.arel_table + awards_table = AwardEmoji.arel_table + + join_clause = awardable_table.join(awards_table, Arel::Nodes::OuterJoin).on( + awards_table[:awardable_id].eq(awardable_table[:id]).and( + awards_table[:awardable_type].eq(self.name).and( + awards_table[:name].eq(emoji_name) + ) + ) + ).join_sources + + joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) DESC") + end + end + + def grouped_awards(with_thumbs: true) + awards = award_emoji.group_by(&:name) + + if with_thumbs + awards[AwardEmoji::UPVOTE_NAME] ||= [] + awards[AwardEmoji::DOWNVOTE_NAME] ||= [] + end + + awards + end + + def downvotes + award_emoji.downvotes.count + end + + def upvotes + award_emoji.upvotes.count + end + + def emoji_awardable? + true + end + + def awarded_emoji?(emoji_name, current_user) + award_emoji.where(name: emoji_name, user: current_user).exists? + end + + def create_award_emoji(name, current_user) + return unless emoji_awardable? + + award_emoji.create(name: name, user: current_user) + end + + def remove_award_emoji(name, current_user) + award_emoji.where(name: name, user: current_user).destroy_all + end + + def toggle_award_emoji(emoji_name, current_user) + if awarded_emoji?(emoji_name, current_user) + remove_award_emoji(emoji_name, current_user) + else + create_award_emoji(emoji_name, current_user) + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 1a4fbbe70d0..5d279ae602a 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -10,6 +10,7 @@ module Issuable include Mentionable include Subscribable include StripAttribute + include Awardable included do belongs_to :author, class_name: "User" @@ -115,29 +116,6 @@ module Issuable end end - def order_downvotes_desc - order_votes_desc('thumbsdown') - end - - def order_upvotes_desc - order_votes_desc('thumbsup') - end - - def order_votes_desc(award_emoji_name) - issuable_table = self.arel_table - note_table = Note.arel_table - - join_clause = issuable_table.join(note_table, Arel::Nodes::OuterJoin).on( - note_table[:noteable_id].eq(issuable_table[:id]).and( - note_table[:noteable_type].eq(self.name).and( - note_table[:is_award].eq(true).and(note_table[:note].eq(award_emoji_name)) - ) - ) - ).join_sources - - joins(join_clause).group(issuable_table[:id]).reorder("COUNT(notes.id) DESC") - end - def with_label(title, sort = nil) if title.is_a?(Array) && title.size > 1 joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}") @@ -179,14 +157,6 @@ module Issuable opened? || reopened? end - def downvotes - notes.awards.where(note: "thumbsdown").count - end - - def upvotes - notes.awards.where(note: "thumbsup").count - end - def user_notes_count notes.user.count end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index bbefc911b29..95fd510eb3a 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -110,6 +110,10 @@ class LegacyDiffNote < Note @active end + def award_emoji_supported? + false + end + private def find_diff diff --git a/app/models/note.rb b/app/models/note.rb index c21981ead84..585d8c4ad84 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -3,6 +3,7 @@ class Note < ActiveRecord::Base include Gitlab::CurrentSettings include Participable include Mentionable + include Awardable default_value_for :system, false @@ -21,11 +22,8 @@ class Note < ActiveRecord::Base delegate :name, :email, to: :author, prefix: true delegate :title, to: :noteable, allow_nil: true - before_validation :set_award! - validates :note, :project, presence: true - validates :note, uniqueness: { scope: [:author, :noteable_type, :noteable_id] }, if: ->(n) { n.is_award } - validates :note, inclusion: { in: Emoji.emojis_names }, if: ->(n) { n.is_award } + # Attachments are deprecated and are handled by Markdown uploader validates :attachment, file_size: { maximum: :max_attachment_size } @@ -43,8 +41,6 @@ class Note < ActiveRecord::Base mount_uploader :attachment, AttachmentUploader # Scopes - scope :awards, ->{ where(is_award: true) } - scope :nonawards, ->{ where(is_award: false) } scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) } scope :system, ->{ where(system: true) } scope :user, ->{ where(system: false) } @@ -109,19 +105,6 @@ class Note < ActiveRecord::Base found_notes.where('issues.confidential IS NULL OR issues.confidential IS FALSE') end end - - def grouped_awards - notes = {} - - awards.select(:note).distinct.map do |note| - notes[note.note] = where(note: note.note) - end - - notes["thumbsup"] ||= Note.none - notes["thumbsdown"] ||= Note.none - - notes - end end def cross_reference? @@ -205,44 +188,24 @@ class Note < ActiveRecord::Base Event.reset_event_cache_for(self) end - def downvote? - is_award && note == "thumbsdown" - end - - def upvote? - is_award && note == "thumbsup" - end - def editable? - !system? && !is_award + !system? end def cross_reference_not_visible_for?(user) cross_reference? && referenced_mentionables(user).empty? end - # Checks if note is an award added as a comment - # - # If note is an award, this method sets is_award to true - # and changes content of the note to award name. - # - # Method is executed as a before_validation callback. - # - def set_award! - return unless awards_supported? && contains_emoji_only? - - self.is_award = true - self.note = award_emoji_name + def award_emoji? + award_emoji_supported? && contains_emoji_only? end - private - def clear_blank_line_code! self.line_code = nil if self.line_code.blank? end - def awards_supported? - (for_issue? || for_merge_request?) && !diff_note? + def award_emoji_supported? + noteable.is_a?(Awardable) end def contains_emoji_only? @@ -251,6 +214,6 @@ class Note < ActiveRecord::Base def award_emoji_name original_name = note.match(Banzai::Filter::EmojiFilter.emoji_pattern)[1] - AwardEmoji.normilize_emoji_name(original_name) + Gitlab::AwardEmoji.normalize_emoji_name(original_name) end end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index 2e5e854fc5e..58cb720c3c1 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -83,7 +83,7 @@ class IrkerService < Service self.channels = recipients.split(/\s+/).map do |recipient| format_channel(recipient) end - channels.reject! &:nil? + channels.reject!(&:nil?) end def format_channel(recipient) diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb new file mode 100644 index 00000000000..00b19686d48 --- /dev/null +++ b/app/models/u2f_registration.rb @@ -0,0 +1,40 @@ +# Registration information for U2F (universal 2nd factor) devices, like Yubikeys + +class U2fRegistration < ActiveRecord::Base + belongs_to :user + + def self.register(user, app_id, json_response, challenges) + u2f = U2F::U2F.new(app_id) + registration = self.new + + begin + response = U2F::RegisterResponse.load_from_json(json_response) + registration_data = u2f.register!(challenges, response) + registration.update(certificate: registration_data.certificate, + key_handle: registration_data.key_handle, + public_key: registration_data.public_key, + counter: registration_data.counter, + user: user) + rescue JSON::ParserError, NoMethodError, ArgumentError + registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.') + rescue U2F::Error => e + registration.errors.add(:base, e.message) + end + + registration + end + + def self.authenticate(user, app_id, json_response, challenges) + response = U2F::SignResponse.load_from_json(json_response) + registration = user.u2f_registrations.find_by_key_handle(response.key_handle) + u2f = U2F::U2F.new(app_id) + + if registration + u2f.authenticate!(challenges, response, Base64.decode64(registration.public_key), registration.counter) + registration.update(counter: response.counter) + true + end + rescue JSON::ParserError, NoMethodError, ArgumentError, U2F::Error + false + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 172845c9d25..e0987e07e1f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -27,7 +27,6 @@ class User < ActiveRecord::Base devise :two_factor_authenticatable, otp_secret_encryption_key: Gitlab::Application.config.secret_key_base - alias_attribute :two_factor_enabled, :otp_required_for_login devise :two_factor_backupable, otp_number_of_backup_codes: 10 serialize :otp_backup_codes, JSON @@ -51,6 +50,7 @@ class User < ActiveRecord::Base has_many :keys, dependent: :destroy has_many :emails, dependent: :destroy has_many :identities, dependent: :destroy, autosave: true + has_many :u2f_registrations, dependent: :destroy # Groups has_many :members, dependent: :destroy @@ -84,6 +84,7 @@ class User < ActiveRecord::Base has_many :builds, dependent: :nullify, class_name: 'Ci::Build' has_many :todos, dependent: :destroy has_many :notification_settings, dependent: :destroy + has_many :award_emoji, as: :awardable, dependent: :destroy # # Validations @@ -174,8 +175,16 @@ class User < ActiveRecord::Base scope :active, -> { with_state(:active) } scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members)') } - scope :with_two_factor, -> { where(two_factor_enabled: true) } - scope :without_two_factor, -> { where(two_factor_enabled: false) } + + def self.with_two_factor + joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id"). + where("u2f.id IS NOT NULL OR otp_required_for_login = ?", true).distinct(arel_table[:id]) + end + + def self.without_two_factor + joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id"). + where("u2f.id IS NULL AND otp_required_for_login = ?", false) + end # # Class methods @@ -322,14 +331,29 @@ class User < ActiveRecord::Base end def disable_two_factor! - update_attributes( - two_factor_enabled: false, - encrypted_otp_secret: nil, - encrypted_otp_secret_iv: nil, - encrypted_otp_secret_salt: nil, - otp_grace_period_started_at: nil, - otp_backup_codes: nil - ) + transaction do + update_attributes( + otp_required_for_login: false, + encrypted_otp_secret: nil, + encrypted_otp_secret_iv: nil, + encrypted_otp_secret_salt: nil, + otp_grace_period_started_at: nil, + otp_backup_codes: nil + ) + self.u2f_registrations.destroy_all + end + end + + def two_factor_enabled? + two_factor_otp_enabled? || two_factor_u2f_enabled? + end + + def two_factor_otp_enabled? + self.otp_required_for_login? + end + + def two_factor_u2f_enabled? + self.u2f_registrations.exists? end def namespace_uniq diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 2b16089df1b..e3dc569152c 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -45,6 +45,8 @@ class IssuableBaseService < BaseService unless can?(current_user, ability, project) params.delete(:milestone_id) + params.delete(:add_label_ids) + params.delete(:remove_label_ids) params.delete(:label_ids) params.delete(:assignee_id) end @@ -67,10 +69,34 @@ class IssuableBaseService < BaseService end def filter_labels - return if params[:label_ids].to_a.empty? + if params[:add_label_ids].present? || params[:remove_label_ids].present? + params.delete(:label_ids) + + filter_labels_in_param(:add_label_ids) + filter_labels_in_param(:remove_label_ids) + else + filter_labels_in_param(:label_ids) + end + end + + def filter_labels_in_param(key) + return if params[key].to_a.empty? - params[:label_ids] = - project.labels.where(id: params[:label_ids]).pluck(:id) + params[key] = project.labels.where(id: params[key]).pluck(:id) + end + + def update_issuable(issuable, attributes) + issuable.with_transaction_returning_status do + add_label_ids = attributes.delete(:add_label_ids) + remove_label_ids = attributes.delete(:remove_label_ids) + + issuable.label_ids |= add_label_ids if add_label_ids + issuable.label_ids -= remove_label_ids if remove_label_ids + + issuable.assign_attributes(attributes.merge(updated_by: current_user)) + + issuable.save + end end def update(issuable) @@ -78,7 +104,7 @@ class IssuableBaseService < BaseService filter_params old_labels = issuable.labels.to_a - if params.present? && issuable.update_attributes(params.merge(updated_by: current_user)) + if params.present? && update_issuable(issuable, params) issuable.reset_events_cache handle_common_system_notes(issuable, old_labels: old_labels) handle_changes(issuable, old_labels: old_labels) diff --git a/app/services/issues/bulk_update_service.rb b/app/services/issues/bulk_update_service.rb index de8387c4900..15825b81685 100644 --- a/app/services/issues/bulk_update_service.rb +++ b/app/services/issues/bulk_update_service.rb @@ -4,9 +4,9 @@ module Issues issues_ids = params.delete(:issues_ids).split(",") issue_params = params - issue_params.delete(:state_event) unless issue_params[:state_event].present? - issue_params.delete(:milestone_id) unless issue_params[:milestone_id].present? - issue_params.delete(:assignee_id) unless issue_params[:assignee_id].present? + %i(state_event milestone_id assignee_id add_label_ids remove_label_ids).each do |key| + issue_params.delete(key) unless issue_params[key].present? + end issues = Issue.where(id: issues_ids) issues.each do |issue| diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index e61628086f0..ab667456db7 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -24,6 +24,7 @@ module Issues @new_issue = create_new_issue rewrite_notes + rewrite_award_emoji add_note_moved_from # Old issue tasks @@ -72,6 +73,14 @@ module Issues end end + def rewrite_award_emoji + @old_issue.award_emoji.each do |award| + new_award = award.dup + new_award.awardable = @new_issue + new_award.save + end + end + def rewrite_content(content) return unless content diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 2bb312bb252..02fca5c0ea3 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -5,6 +5,13 @@ module Notes note.author = current_user note.system = false + if note.award_emoji? + noteable = note.noteable + todo_service.new_award_emoji(noteable, current_user) + + return noteable.create_award_emoji(note.award_emoji_name, current_user) + end + if note.save # Finish the harder work in the background NewNoteWorker.perform_in(2.seconds, note.id, params) diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index e818f58d13c..534c48aefff 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -8,7 +8,7 @@ module Notes def execute # Skip system notes, like status changes and cross-references and awards - unless @note.system || @note.is_award + unless @note.system? EventCreateService.new.leave_note(@note, @note.author) @note.create_cross_references! execute_note_hooks diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 42ec1ac9e1a..91ca82ed3b7 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -130,8 +130,7 @@ class NotificationService # ignore gitlab service messages return true if note.note.start_with?('Status changed to closed') - return true if note.cross_reference? && note.system == true - return true if note.is_award + return true if note.cross_reference? && note.system? target = note.noteable diff --git a/app/services/oauth2/access_token_validation_service.rb b/app/services/oauth2/access_token_validation_service.rb index 6194f6ce91e..264fdccde8f 100644 --- a/app/services/oauth2/access_token_validation_service.rb +++ b/app/services/oauth2/access_token_validation_service.rb @@ -22,6 +22,7 @@ module Oauth2::AccessTokenValidationService end protected + # True if the token's scope is a superset of required scopes, # or the required scopes is empty. def sufficient_scope?(token, scopes) diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 4bf4e144727..d8365124175 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -122,6 +122,14 @@ class TodoService handle_note(note, current_user) end + # When an emoji is awarded we should: + # + # * mark all pending todos related to the awardable for the current user as done + # + def new_award_emoji(awardable, current_user) + mark_pending_todos_as_done(awardable, current_user) + end + # When marking pending todos as done we should: # # * mark all pending todos related to the target for the current user as done diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml new file mode 100644 index 00000000000..84fd146a26b --- /dev/null +++ b/app/views/award_emoji/_awards_block.html.haml @@ -0,0 +1,18 @@ +- grouped_emojis = awardable.grouped_awards(with_thumbs: inline) +.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) } } + - awards_sort(grouped_emojis).each do |emoji, awards| + %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", class: (award_active_class(awards, current_user)), data: { placement: "bottom", title: award_user_list(awards, current_user) } } + = emoji_icon(emoji, sprite: false) + %span.award-control-text.js-counter + = awards.count + + - if current_user + :javascript + gl.awardMenuUrl = "#{emojis_path}" + + .award-menu-holder.js-award-holder + %button.btn.award-control.js-add-award{ type: "button" } + = icon('smile-o', class: "award-control-icon award-control-icon-normal") + = icon('spinner spin', class: "award-control-icon award-control-icon-loading") + %span.award-control-text + Add diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index 8c6a1552a53..9d04db2c45e 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -1,11 +1,18 @@ %div .login-box .login-heading - %h3 Two-factor Authentication + %h3 Two-Factor Authentication .login-body - = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| - = f.hidden_field :remember_me, value: params[resource_name][:remember_me] - = f.text_field :otp_attempt, class: 'form-control', placeholder: 'Two-factor Authentication code', required: true, autofocus: true - %p.help-block.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes. - .prepend-top-20 - = f.submit "Verify code", class: "btn btn-save" + - if @user.two_factor_otp_enabled? + %h5 Authenticate via Two-Factor App + = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| + = f.hidden_field :remember_me, value: params[resource_name][:remember_me] + = f.text_field :otp_attempt, class: 'form-control', placeholder: 'Two-Factor Authentication code', required: true, autofocus: true, autocomplete: 'off' + %p.help-block.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes. + .prepend-top-20 + = f.submit "Verify code", class: "btn btn-save" + + - if @user.two_factor_u2f_enabled? + + %hr + = render "u2f/authenticate" diff --git a/app/views/emojis/index.html.haml b/app/views/emojis/index.html.haml index 3443a8e2307..97401a2e618 100644 --- a/app/views/emojis/index.html.haml +++ b/app/views/emojis/index.html.haml @@ -1,9 +1,9 @@ .emoji-menu .emoji-menu-content = text_field_tag :emoji_search, "", class: "emoji-search search-input form-control" - - AwardEmoji.emoji_by_category.each do |category, emojis| + - Gitlab::AwardEmoji.emoji_by_category.each do |category, emojis| %h5.emoji-menu-title - = AwardEmoji::CATEGORIES[category] + = Gitlab::AwardEmoji::CATEGORIES[category] %ul.clearfix.emoji-menu-list - emojis.each do |emoji| %li.pull-left.text-center.emoji-menu-list-item diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index c7f29f2fc0e..2e2403347c1 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -1,10 +1,14 @@ .event-title %span.author_name= link_to_author event %span.event_label{class: event.action_name} - = event_action_name(event) - - if event.target - %strong= link_to event.target.reference_link_text, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title + = event.action_name + %strong + = link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title do + = event.target_type.titleize.downcase + = event.target.reference_link_text + - else + = event_action_name(event) = event_preposition(event) diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 70e88da7aae..01648047ce2 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -24,7 +24,7 @@ %td Show/hide this dialog %tr %td.shortcut - - if browser.mac? + - if browser.platform.mac? .key ⌘ shift p - else .key ctrl shift p diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index b30fb0a5da9..e0ed657919e 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -35,8 +35,6 @@ = csrf_meta_tags - = include_gon - - unless browser.safari? %meta{name: 'referrer', content: 'origin-when-cross-origin'} %meta{name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1'} diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index e4d1c773d03..2b86b289bbe 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -2,6 +2,8 @@ %html{ lang: "en"} = render "layouts/head" %body{class: "#{user_application_theme}", 'data-page' => body_data_page} + = Gon::Base.render_data + -# Ideally this would be inside the head, but turbolinks only evaluates page-specific JS in the body. = yield :scripts_body_top diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index f08cb0a5428..3d28eec84ef 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -2,6 +2,7 @@ %html{ lang: "en"} = render "layouts/head" %body.ui_charcoal.login-page.application.navless + = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 7c061dd531f..6bd427b02ac 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -2,6 +2,7 @@ %html{ lang: "en"} = render "layouts/head" %body.ui_charcoal.login-page.application.navless + = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml index 915acc4612e..7fbe065df00 100644 --- a/app/views/layouts/errors.html.haml +++ b/app/views/layouts/errors.html.haml @@ -2,6 +2,7 @@ %html{ lang: "en"} = render "layouts/head" %body{class: "#{user_application_theme} application navless"} + = Gon::Base.render_data = render "layouts/header/empty" .container.navless-container = render "layouts/flash" diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 9792c1c93b4..03c9fa0a94d 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -51,7 +51,7 @@ = link_to project_container_registry_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do = icon('hdd-o fw') %span - Container Registry + Registry - if project_nav_tab? :graphs = nav_link(controller: %w(graphs)) do diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 01ac8161945..3d2a245ecbd 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -11,7 +11,7 @@ %p Your private token is used to access application resources without authentication. .col-lg-9 - = form_for @user, url: reset_private_token_profile_path, method: :put, html: {class: "private-token"} do |f| + = form_for @user, url: reset_private_token_profile_path, method: :put, html: { class: "private-token" } do |f| %p.cgray - if current_user.private_token = label_tag "token", "Private token", class: "label-light" @@ -29,21 +29,22 @@ .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 - Two-factor Authentication + Two-Factor Authentication %p - Increase your account's security by enabling two-factor authentication (2FA). + Increase your account's security by enabling Two-Factor Authentication (2FA). .col-lg-9 %p - Status: #{current_user.two_factor_enabled? ? 'enabled' : 'disabled'} - - if !current_user.two_factor_enabled? - %p - Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code. - More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. - .append-bottom-10 - = link_to 'Enable two-factor authentication', new_profile_two_factor_auth_path, class: 'btn btn-success' + Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'} + - if current_user.two_factor_enabled? + = link_to 'Manage Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-info' + = link_to 'Disable', profile_two_factor_auth_path, + method: :delete, + data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." }, + class: 'btn btn-danger' - else - = link_to 'Disable Two-factor Authentication', profile_two_factor_auth_path, method: :delete, class: 'btn btn-danger', - data: { confirm: 'Are you sure?' } + .append-bottom-10 + = link_to 'Enable Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-success' + %hr - if button_based_providers.any? .row.prepend-top-default diff --git a/app/views/profiles/two_factor_auths/new.html.haml b/app/views/profiles/two_factor_auths/new.html.haml deleted file mode 100644 index 69fc81cb45c..00000000000 --- a/app/views/profiles/two_factor_auths/new.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -- page_title 'Two-factor Authentication', 'Account' - -.row.prepend-top-default - .col-lg-3 - %h4.prepend-top-0 - Two-factor Authentication (2FA) - %p - Increase your account's security by enabling two-factor authentication (2FA). - .col-lg-9 - %p - Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code. - More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. - .row.append-bottom-10 - .col-md-3 - = raw @qr_code - .col-md-9 - .account-well - %p.prepend-top-0.append-bottom-0 - Can't scan the code? - %p.prepend-top-0.append-bottom-0 - To add the entry manually, provide the following details to the application on your phone. - %p.prepend-top-0.append-bottom-0 - Account: - = current_user.email - %p.prepend-top-0.append-bottom-0 - Key: - = current_user.otp_secret.scan(/.{4}/).join(' ') - %p.two-factor-new-manual-content - Time based: Yes - = form_tag profile_two_factor_auth_path, method: :post do |f| - - if @error - .alert.alert-danger - = @error - .form-group - = label_tag :pin_code, nil, class: "label-light" - = text_field_tag :pin_code, nil, class: "form-control", required: true - .prepend-top-default - = submit_tag 'Enable two-factor authentication', class: 'btn btn-success' - = link_to 'Configure it later', skip_profile_two_factor_auth_path, :method => :patch, class: 'btn btn-cancel' if two_factor_skippable? diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml new file mode 100644 index 00000000000..ce76cb73c9c --- /dev/null +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -0,0 +1,69 @@ +- page_title 'Two-Factor Authentication', 'Account' +- header_title "Two-Factor Authentication", profile_two_factor_auth_path + +.row.prepend-top-default + .col-lg-3 + %h4.prepend-top-0 + Register Two-Factor Authentication App + %p + Use an app on your mobile device to enable two-factor authentication (2FA). + .col-lg-9 + - if current_user.two_factor_otp_enabled? + = icon "check inverse", base: "circle", class: "text-success", text: "You've already enabled two-factor authentication using mobile authenticator applications. You can disable it from your account settings page." + - else + %p + Download the Google Authenticator application from App Store or Google Play Store and scan this code. + More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}. + .row.append-bottom-10 + .col-md-3 + = raw @qr_code + .col-md-9 + .account-well + %p.prepend-top-0.append-bottom-0 + Can't scan the code? + %p.prepend-top-0.append-bottom-0 + To add the entry manually, provide the following details to the application on your phone. + %p.prepend-top-0.append-bottom-0 + Account: + = current_user.email + %p.prepend-top-0.append-bottom-0 + Key: + = current_user.otp_secret.scan(/.{4}/).join(' ') + %p.two-factor-new-manual-content + Time based: Yes + = form_tag profile_two_factor_auth_path, method: :post do |f| + - if @error + .alert.alert-danger + = @error + .form-group + = label_tag :pin_code, nil, class: "label-light" + = text_field_tag :pin_code, nil, class: "form-control", required: true + .prepend-top-default + = submit_tag 'Register with Two-Factor App', class: 'btn btn-success' + +%hr + +.row.prepend-top-default + + .col-lg-3 + %h4.prepend-top-0 + Register Universal Two-Factor (U2F) Device + %p + Use a hardware device to add the second factor of authentication. + %p + As U2F devices are only supported by a few browsers, it's recommended that you set up a + two-factor authentication app as well as a U2F device so you'll always be able to log in + using an unsupported browser. + .col-lg-9 + %p + - if @registration_key_handles.present? + = icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab." + - if @u2f_registration.errors.present? + = form_errors(@u2f_registration) + = render "u2f/register" + +- if two_factor_skippable? + :javascript + var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>"; + $(".flash-alert").append(button); + diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 81afea2c60a..28a28282fd3 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -7,6 +7,12 @@ %li %a.js-md-preview-button{ href: "#md-preview-holder", tabindex: -1 } Preview + + - if defined?(@issue) && @issue.confidential? + %li.confidential-issue-warning + = icon('warning') + %span This is a confidential issue. Your comment will not be visible to the public. + %li.pull-right %button.zen-control.zen-control-full.js-zen-enter{ type: 'button', tabindex: -1 } Go full screen diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 78f64150601..79b14819865 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,4 +1,4 @@ -%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue) } +%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } } - if controller.controller_name == 'issues' && can?(current_user, :admin_issue, @project) .issue-check = check_box_tag dom_id(issue,"selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" @@ -27,7 +27,7 @@ = icon('thumbs-down') = downvotes - - note_count = issue.notes.user.nonawards.count + - note_count = issue.notes.user.count %li = link_to issue_path(issue, anchor: 'notes'), class: ('issue-no-comments' if note_count.zero?) do = icon('comments') diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index f3b0469b7d4..b2f14a54073 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -70,7 +70,7 @@ .content-block.content-block-small = render 'new_branch' - = render 'votes/votes_block', votable: @issue + = render 'award_emoji/awards_block', awardable: @issue, inline: true %section.issuable-discussion = render 'projects/issues/discussion' diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index 8bf544b8371..294fec422c5 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -1,6 +1,5 @@ -%li{id: dom_id(label)} +%li{ id: dom_id(label), data: { id: label.id } } = render "shared/label_row", label: label - .pull-info-right %span.append-right-20 = link_to_label(label, type: :merge_request) do diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index c02f94490a0..1ec180235ce 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -35,7 +35,7 @@ = icon('thumbs-down') = downvotes - - note_count = merge_request.mr_and_commit_notes.user.nonawards.count + - note_count = merge_request.mr_and_commit_notes.user.count %li = link_to merge_request_path(merge_request, anchor: 'notes'), class: ('merge-request-no-comments' if note_count.zero?) do = icon('comments') diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml index 5473fa19166..446887774a4 100644 --- a/app/views/projects/merge_requests/_merge_requests.html.haml +++ b/app/views/projects/merge_requests/_merge_requests.html.haml @@ -6,4 +6,3 @@ - if @merge_requests.present? = paginate @merge_requests, theme: "gitlab" - diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 7af227129ec..a73d0063be2 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -49,7 +49,7 @@ %li.notes-tab = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do Discussion - %span.badge= @merge_request.mr_and_commit_notes.user.nonawards.count + %span.badge= @merge_request.mr_and_commit_notes.user.count %li.commits-tab = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do Commits @@ -67,7 +67,7 @@ .tab-content #notes.notes.tab-pane.voting_notes .content-block.content-block-small.oneline-block - = render 'votes/votes_block', votable: @merge_request + = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .row %section.col-md-12 diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index f1045bbd8c3..5ddd0ecc4c1 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -19,20 +19,24 @@ .note-actions - access = note.project.team.human_max_access(note.author.id) - if access - %span.note-role - = access + %span.note-role.hidden-xs= access - if note_editable + = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do + = icon('spinner spin') + = icon('smile-o') = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do = icon('pencil') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do = icon('trash-o') .note-body{class: note_editable ? 'js-task-list-container' : ''} .note-text = preserve do = markdown(note.note, pipeline: :note, cache_key: [note, "note"], author: note.author) + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) - if note_editable = render 'projects/notes/edit_form', note: note - = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) + .note-awards + = render 'award_emoji/awards_block', awardable: note, inline: false - if note.attachment.url .note-attachment diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index cedff4af2e0..380ab465bf4 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -31,7 +31,7 @@ - if controller.controller_name == 'issues' .issues_bulk_update.hide - = form_tag bulk_update_namespace_project_issues_path(@project.namespace, @project), method: :post do + = form_tag bulk_update_namespace_project_issues_path(@project.namespace, @project), method: :post, class: 'bulk-update' do .filter-item.inline = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do %ul @@ -44,6 +44,10 @@ placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + + .filter-item.inline.labels-filter + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = hidden_field_tag 'update[issues_ids]', [] = hidden_field_tag :state_event, params[:state_event] .filter-item.inline diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index 61fd1e9c335..d34d28f6736 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -1,14 +1,25 @@ +- show_create = local_assigns.fetch(:show_create, true) +- extra_options = local_assigns.fetch(:extra_options, true) +- filter_submit = local_assigns.fetch(:filter_submit, true) +- show_footer = local_assigns.fetch(:show_footer, true) +- data_options = local_assigns.fetch(:data_options, {}) +- classes = local_assigns.fetch(:classes, []) +- dropdown_data = {toggle: 'dropdown', field_name: 'label_name[]', show_no: "true", show_any: "true", selected: params[:label_name], project_id: @project.try(:id), labels: labels_filter_path, default_label: "Label"} +- dropdown_data.merge!(data_options) +- classes << 'js-extra-options' if extra_options +- classes << 'js-filter-submit' if filter_submit + - if params[:label_name].present? - if params[:label_name].respond_to?('any?') - params[:label_name].each do |label| = hidden_field_tag "label_name[]", label, id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-filter-submit.js-multiselect.js-extra-options{type: "button", data: {toggle: "dropdown", field_name: "label_name[]", show_no: "true", show_any: "true", selected: params[:label_name], project_id: @project.try(:id), labels: labels_filter_path, default_label: "Label"}} + %button.dropdown-menu-toggle.js-label-select.js-multiselect{class: classes.join(' '), type: "button", data: dropdown_data} %span.dropdown-toggle-text = h(multi_label_name(params[:label_name], "Label")) = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable - = render partial: "shared/issuable/label_page_default", locals: { title: "Filter by label" } - - if can? current_user, :admin_label, @project and @project + = render partial: "shared/issuable/label_page_default", locals: { title: "Filter by label", show_footer: show_footer, show_create: show_create } + - if show_create and @project and can?(current_user, :admin_label, @project) = render partial: "shared/issuable/label_page_create" = dropdown_loading diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index 7f4867417f7..4e280c371ac 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -1,20 +1,22 @@ - title = local_assigns.fetch(:title, 'Assign labels') +- show_create = local_assigns.fetch(:show_create, true) +- show_footer = local_assigns.fetch(:show_footer, true) - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels') .dropdown-page-one = dropdown_title(title) = dropdown_filter(filter_placeholder) = dropdown_content - - if @project + - if @project && show_footer = dropdown_footer do %ul.dropdown-footer-list - - if can? current_user, :admin_label, @project + - if can?(current_user, :admin_label, @project) %li %a.dropdown-toggle-page{href: "#"} Create new %li = link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do - - if can? current_user, :admin_label, @project + - if show_create && @project && can?(current_user, :admin_label, @project) Manage labels - else View labels - = dropdown_loading
\ No newline at end of file + = dropdown_loading diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index d6552ae7f18..1ec2436c835 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -2,23 +2,8 @@ .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - %span.issuable-count.hide-collapsed.pull-left - = issuable.iid - of - = issuables_count(issuable) %a.gutter-toggle.pull-right.js-sidebar-toggle{href: '#'} = sidebar_gutter_toggle_icon - .issuable-nav.hide-collapsed.pull-right.btn-group{role: 'group', "aria-label" => '...'} - - if prev_issuable = prev_issuable_for(issuable) - = link_to 'Prev', [@project.namespace.becomes(Namespace), @project, prev_issuable], class: 'btn btn-default prev-btn issuable-pager' - - else - %a.btn.btn-default.issuable-pager.disabled{href: '#'} - Prev - - if next_issuable = next_issuable_for(issuable) - = link_to 'Next', [@project.namespace.becomes(Namespace), @project, next_issuable], class: 'btn btn-default next-btn issuable-pager' - - else - %a.btn.btn-default.issuable-pager.disabled{href: '#'} - Next = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f| .block.assignee diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml new file mode 100644 index 00000000000..75fb0e303ad --- /dev/null +++ b/app/views/u2f/_authenticate.html.haml @@ -0,0 +1,28 @@ +#js-authenticate-u2f + +%script#js-authenticate-u2f-not-supported{ type: "text/template" } + %p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer). + +%script#js-authenticate-u2f-setup{ type: "text/template" } + %div + %p Insert your security key (if you haven't already), and press the button below. + %a.btn.btn-info#js-login-u2f-device{ href: 'javascript:void(0)' } Login Via U2F Device + +%script#js-authenticate-u2f-in-progress{ type: "text/template" } + %p Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now. + +%script#js-authenticate-u2f-error{ type: "text/template" } + %div + %p <%= error_message %> + %a.btn.btn-warning#js-u2f-try-again Try again? + +%script#js-authenticate-u2f-authenticated{ type: "text/template" } + %div + %p We heard back from your U2F device. Click this button to authenticate with the GitLab server. + = form_tag(new_user_session_path, method: :post) do |f| + = hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" + = submit_tag "Authenticate via U2F Device", class: "btn btn-success" + +:javascript + var u2fAuthenticate = new U2FAuthenticate($("#js-authenticate-u2f"), gon.u2f); + u2fAuthenticate.start(); diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml new file mode 100644 index 00000000000..46af591fc43 --- /dev/null +++ b/app/views/u2f/_register.html.haml @@ -0,0 +1,31 @@ +#js-register-u2f + +%script#js-register-u2f-not-supported{ type: "text/template" } + %p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer). + +%script#js-register-u2f-setup{ type: "text/template" } + .row.append-bottom-10 + .col-md-3 + %a#js-setup-u2f-device.btn.btn-info{ href: 'javascript:void(0)' } Setup New U2F Device + .col-md-9 + %p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left. + +%script#js-register-u2f-in-progress{ type: "text/template" } + %p Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now. + +%script#js-register-u2f-error{ type: "text/template" } + %div + %p + %span <%= error_message %> + %a.btn.btn-warning#js-u2f-try-again Try again? + +%script#js-register-u2f-registered{ type: "text/template" } + %div.row.append-bottom-10 + %p Your device was successfully set up! Click this button to register with the GitLab server. + = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do + = hidden_field_tag :device_response, nil, class: 'form-control', required: true, id: "js-device-response" + = submit_tag "Register U2F Device", class: "btn btn-success" + +:javascript + var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f); + u2fRegister.start(); diff --git a/app/views/votes/_votes_block.html.haml b/app/views/votes/_votes_block.html.haml deleted file mode 100644 index 4beb8746444..00000000000 --- a/app/views/votes/_votes_block.html.haml +++ /dev/null @@ -1,30 +0,0 @@ -.awards.votes-block - - awards_sort(votable.notes.awards.grouped_awards).each do |emoji, notes| - %button.btn.award-control.js-emoji-btn.has-tooltip{class: (note_active_class(notes, current_user)), data: {placement: "top", original_title: emoji_author_list(notes, current_user)}} - = emoji_icon(emoji, sprite: false) - %span.award-control-text.js-counter - = notes.count - - - if current_user - %div.award-menu-holder.js-award-holder - %a.btn.award-control.js-add-award{"href" => "#"} - = icon('smile-o', {class: "award-control-icon"}) - = icon('spinner spin', {class: "award-control-icon award-control-icon-loading"}) - %span.award-control-text - Add - -- if current_user - :javascript - var getEmojisUrl = "#{emojis_path}"; - var postEmojiUrl = "#{award_toggle_namespace_project_notes_path(@project.namespace, @project)}"; - var noteableType = "#{votable.class.name.underscore}"; - var noteableId = "#{votable.id}"; - var unicodes = #{AwardEmoji.unicode.to_json}; - - window.awardsHandler = new AwardsHandler( - getEmojisUrl, - postEmojiUrl, - noteableType, - noteableId, - unicodes - ); diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 9e8b0131f8f..3d1a41a4652 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -8,3 +8,7 @@ # inflect.irregular 'person', 'people' # inflect.uncountable %w( fish sheep ) # end +# +ActiveSupport::Inflector.inflections do |inflect| + inflect.uncountable %w(award_emoji) +end diff --git a/config/routes.rb b/config/routes.rb index 428302d0fd7..7e735541f7f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -343,8 +343,9 @@ Rails.application.routes.draw do resources :keys resources :emails, only: [:index, :create, :destroy] resource :avatar, only: [:destroy] - resource :two_factor_auth, only: [:new, :create, :destroy] do + resource :two_factor_auth, only: [:show, :create, :destroy] do member do + post :create_u2f post :codes patch :skip end @@ -652,6 +653,7 @@ Rails.application.routes.draw do post :cancel_merge_when_build_succeeds get :ci_status post :toggle_subscription + post :toggle_award_emoji post :remove_wip end @@ -727,6 +729,7 @@ Rails.application.routes.draw do resources :issues, constraints: { id: /\d+/ } do member do post :toggle_subscription + post :toggle_award_emoji get :referenced_merge_requests get :related_branches get :can_create_branch @@ -755,12 +758,9 @@ Rails.application.routes.draw do resources :notes, only: [:index, :create, :destroy, :update], constraints: { id: /\d+/ } do member do + post :toggle_award_emoji delete :delete_attachment end - - collection do - post :award_toggle - end end resources :uploads, only: [:create] do diff --git a/db/migrate/20160416180807_add_award_emoji.rb b/db/migrate/20160416180807_add_award_emoji.rb new file mode 100644 index 00000000000..2ead181921b --- /dev/null +++ b/db/migrate/20160416180807_add_award_emoji.rb @@ -0,0 +1,14 @@ +class AddAwardEmoji < ActiveRecord::Migration + def change + create_table :award_emoji do |t| + t.string :name + t.references :user + t.references :awardable, polymorphic: true + + t.timestamps + end + + add_index :award_emoji, :user_id + add_index :award_emoji, [:awardable_type, :awardable_id] + end +end diff --git a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb new file mode 100644 index 00000000000..073bbc0fc2a --- /dev/null +++ b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb @@ -0,0 +1,9 @@ +class ConvertAwardNoteToEmojiAward < ActiveRecord::Migration + def change + def up + execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" + + execute "DELETE FROM notes WHERE is_award = true" + end + end +end diff --git a/db/migrate/20160416190505_remove_note_is_award.rb b/db/migrate/20160416190505_remove_note_is_award.rb new file mode 100644 index 00000000000..da16372a297 --- /dev/null +++ b/db/migrate/20160416190505_remove_note_is_award.rb @@ -0,0 +1,5 @@ +class RemoveNoteIsAward < ActiveRecord::Migration + def change + remove_column :notes, :is_award, :boolean + end +end diff --git a/db/migrate/20160425045124_create_u2f_registrations.rb b/db/migrate/20160425045124_create_u2f_registrations.rb new file mode 100644 index 00000000000..93bdd9de2eb --- /dev/null +++ b/db/migrate/20160425045124_create_u2f_registrations.rb @@ -0,0 +1,13 @@ +class CreateU2fRegistrations < ActiveRecord::Migration + def change + create_table :u2f_registrations do |t| + t.text :certificate + t.string :key_handle, index: true + t.string :public_key + t.integer :counter + t.references :user, index: true, foreign_key: true + + t.timestamps null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b2af810f600..9b991f347a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,6 @@ # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema.define(version: 20160530150109) do - # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "pg_trgm" @@ -100,6 +99,18 @@ ActiveRecord::Schema.define(version: 20160530150109) do add_index "audit_events", ["entity_id", "entity_type"], name: "index_audit_events_on_entity_id_and_entity_type", using: :btree add_index "audit_events", ["type"], name: "index_audit_events_on_type", using: :btree + create_table "award_emoji", force: :cascade do |t| + t.string "name" + t.integer "user_id" + t.integer "awardable_id" + t.string "awardable_type" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "award_emoji", ["awardable_type", "awardable_id"], name: "index_award_emoji_on_awardable_type_and_awardable_id", using: :btree + add_index "award_emoji", ["user_id"], name: "index_award_emoji_on_user_id", using: :btree + create_table "broadcast_messages", force: :cascade do |t| t.text "message", null: false t.datetime "starts_at" @@ -638,7 +649,6 @@ ActiveRecord::Schema.define(version: 20160530150109) do t.boolean "system", default: false, null: false t.text "st_diff" t.integer "updated_by_id" - t.boolean "is_award", default: false, null: false t.string "type" end @@ -646,7 +656,6 @@ ActiveRecord::Schema.define(version: 20160530150109) do add_index "notes", ["commit_id"], name: "index_notes_on_commit_id", using: :btree add_index "notes", ["created_at", "id"], name: "index_notes_on_created_at_and_id", using: :btree add_index "notes", ["created_at"], name: "index_notes_on_created_at", using: :btree - add_index "notes", ["is_award"], name: "index_notes_on_is_award", using: :btree add_index "notes", ["line_code"], name: "index_notes_on_line_code", using: :btree add_index "notes", ["note"], name: "index_notes_on_note_trigram", using: :gin, opclasses: {"note"=>"gin_trgm_ops"} add_index "notes", ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type", using: :btree @@ -930,6 +939,19 @@ ActiveRecord::Schema.define(version: 20160530150109) do add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree add_index "todos", ["user_id"], name: "index_todos_on_user_id", using: :btree + create_table "u2f_registrations", force: :cascade do |t| + t.text "certificate" + t.string "key_handle" + t.string "public_key" + t.integer "counter" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree + add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -1037,4 +1059,5 @@ ActiveRecord::Schema.define(version: 20160530150109) do add_index "web_hooks", ["created_at", "id"], name: "index_web_hooks_on_created_at_and_id", using: :btree add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree + add_foreign_key "u2f_registrations", "users" end diff --git a/doc/api/builds.md b/doc/api/builds.md index 4c0a47d1ea0..5669bd0cdda 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -278,6 +278,30 @@ Response: [ce-2893]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2893 +## Get a trace file + +Get a trace of a specific build of a project + +``` +GET /projects/:id/builds/:build_id/trace +``` + +| Attribute | Type | Required | Description | +|------------|---------|----------|---------------------| +| id | integer | yes | The ID of a project | +| build_id | integer | yes | The ID of a build | + +``` +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/8/trace" +``` + +Response: + +| Status | Description | +|-----------|-----------------------------------| +| 200 | Serves the trace file | +| 404 | Build not found or no trace file | + ## Cancel a build Cancel a single build of a project diff --git a/doc/profile/2fa_u2f_authenticate.png b/doc/profile/2fa_u2f_authenticate.png Binary files differnew file mode 100644 index 00000000000..b9138ff60db --- /dev/null +++ b/doc/profile/2fa_u2f_authenticate.png diff --git a/doc/profile/2fa_u2f_register.png b/doc/profile/2fa_u2f_register.png Binary files differnew file mode 100644 index 00000000000..15b3683ef73 --- /dev/null +++ b/doc/profile/2fa_u2f_register.png diff --git a/doc/profile/two_factor_authentication.md b/doc/profile/two_factor_authentication.md index a0e23c1586c..82505b13401 100644 --- a/doc/profile/two_factor_authentication.md +++ b/doc/profile/two_factor_authentication.md @@ -8,12 +8,27 @@ your phone. By enabling 2FA, the only way someone other than you can log into your account is to know your username and password *and* have access to your phone. -#### Note +> **Note:** When you enable 2FA, don't forget to back up your recovery codes. For your safety, if you lose your codes for GitLab.com, we can't disable or recover them. +In addition to a phone application, GitLab supports U2F (universal 2nd factor) devices as +the second factor of authentication. Once enabled, in addition to supplying your username and +password to login, you'll be prompted to activate your U2F device (usually by pressing +a button on it), and it will perform secure authentication on your behalf. + +> **Note:** Support for U2F devices was added in version 8.8 + +The U2F workflow is only supported by Google Chrome at this point, so we _strongly_ recommend +that you set up both methods of two-factor authentication, so you can still access your account +from other browsers. + +> **Note:** GitLab officially only supports [Yubikey] U2F devices. + ## Enabling 2FA +### Enable 2FA via mobile application + **In GitLab:** 1. Log in to your GitLab account. @@ -38,9 +53,26 @@ lose your codes for GitLab.com, we can't disable or recover them. 1. Click **Submit**. If the pin you entered was correct, you'll see a message indicating that -Two-factor Authentication has been enabled, and you'll be presented with a list +Two-Factor Authentication has been enabled, and you'll be presented with a list of recovery codes. +### Enable 2FA via U2F device + +**In GitLab:** + +1. Log in to your GitLab account. +1. Go to your **Profile Settings**. +1. Go to **Account**. +1. Click **Enable Two-Factor Authentication**. +1. Plug in your U2F device. +1. Click on **Setup New U2F Device**. +1. A light will start blinking on your device. Activate it by pressing its button. + +You will see a message indicating that your device was successfully set up. +Click on **Register U2F Device** to complete the process. + + + ## Recovery Codes Should you ever lose access to your phone, you can use one of the ten provided @@ -51,21 +83,39 @@ account. If you lose the recovery codes or just want to generate new ones, you can do so from the **Profile Settings** > **Account** page where you first enabled 2FA. +> **Note:** Recovery codes are not generated for U2F devices. + ## Logging in with 2FA Enabled Logging in with 2FA enabled is only slightly different than a normal login. Enter your username and password credentials as you normally would, and you'll -be presented with a second prompt for an authentication code. Enter the pin from -your phone's application or a recovery code to log in. +be presented with a second prompt, depending on which type of 2FA you've enabled. + +### Log in via mobile application + +Enter the pin from your phone's application or a recovery code to log in. - + + +### Log in via U2F device + +1. Click **Login via U2F Device** +1. A light will start blinking on your device. Activate it by pressing its button. + +You will see a message indicating that your device responded to the authentication request. +Click on **Authenticate via U2F Device** to complete the process. + + ## Disabling 2FA 1. Log in to your GitLab account. 1. Go to your **Profile Settings**. 1. Go to **Account**. -1. Click **Disable Two-factor Authentication**. +1. Click **Disable**, under **Two-Factor Authentication**. + +This will clear all your two-factor authentication registrations, including mobile +applications and U2F devices. ## Note to GitLab administrators @@ -74,3 +124,4 @@ You need to take special care to that 2FA keeps working after [Google Authenticator]: https://support.google.com/accounts/answer/1066447?hl=en [FreeOTP]: https://fedorahosted.org/freeotp/ +[YubiKey]: https://www.yubico.com/products/yubikey-hardware/ diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index de7e2b37725..2259b7125c4 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -25,13 +25,6 @@ Feature: Project Issues Scenario: I visit issue page Given I click link "Release 0.4" Then I should see issue "Release 0.4" - And I should see "1 of 2" in the sidebar - - Scenario: I navigate between issues - Given I click link "Release 0.4" - Then I click link "Next" in the sidebar - Then I should see issue "Tweet control" - And I should see "2 of 2" in the sidebar @javascript Scenario: I filter by author diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index ecda4ea8240..396eb7cc11b 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -49,14 +49,12 @@ Feature: Project Merge Requests Scenario: I visit an open merge request page Given I click link "Bug NS-04" Then I should see merge request "Bug NS-04" - And I should see "1 of 1" in the sidebar Scenario: I visit a merged merge request page Given project "Shop" have "Feature NS-05" merged merge request And I click link "Merged" And I click link "Feature NS-05" Then I should see merge request "Feature NS-05" - And I should see "3 of 3" in the sidebar Scenario: I close merge request page Given I click link "Bug NS-04" diff --git a/features/steps/project/issues/filter_labels.rb b/features/steps/project/issues/filter_labels.rb index d82c6856918..d34fa694789 100644 --- a/features/steps/project/issues/filter_labels.rb +++ b/features/steps/project/issues/filter_labels.rb @@ -29,7 +29,7 @@ class Spinach::Features::ProjectIssuesFilterLabels < Spinach::FeatureSteps end step 'I click link "bug"' do - page.find('.js-label-select').click + page.find('.js-label-select', visible: true).click sleep 0.5 execute_script("$('.dropdown-menu-labels li:contains(\"bug\") a').click()") end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 5cd431e05d5..439363e6f14 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -191,15 +191,15 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'issue "Release 0.4" have 2 upvotes and 1 downvote' do - issue = Issue.find_by(title: 'Release 0.4') - create_list(:upvote_note, 2, project: project, noteable: issue) - create(:downvote_note, project: project, noteable: issue) + awardable = Issue.find_by(title: 'Release 0.4') + create_list(:award_emoji, 2, awardable: awardable) + create(:award_emoji, :downvote, awardable: awardable) end step 'issue "Tweet control" have 1 upvote and 2 downvotes' do - issue = Issue.find_by(title: 'Tweet control') - create(:upvote_note, project: project, noteable: issue) - create_list(:downvote_note, 2, project: project, noteable: issue) + awardable = Issue.find_by(title: 'Tweet control') + create(:award_emoji, :upvote, awardable: awardable) + create_list(:award_emoji, 2, awardable: awardable, name: 'thumbsdown') end step 'The list should be sorted by "Least popular"' do diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index b30346790eb..1dd6cbef615 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -179,14 +179,14 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'merge request "Bug NS-04" have 2 upvotes and 1 downvote' do merge_request = MergeRequest.find_by(title: 'Bug NS-04') - create_list(:upvote_note, 2, project: project, noteable: merge_request) - create(:downvote_note, project: project, noteable: merge_request) + create_list(:award_emoji, 2, awardable: merge_request) + create(:award_emoji, :downvote, awardable: merge_request) end step 'merge request "Bug NS-06" have 1 upvote and 2 downvotes' do - merge_request = MergeRequest.find_by(title: 'Bug NS-06') - create(:upvote_note, project: project, noteable: merge_request) - create_list(:downvote_note, 2, project: project, noteable: merge_request) + awardable = MergeRequest.find_by(title: 'Bug NS-06') + create(:award_emoji, awardable: awardable) + create_list(:award_emoji, 2, :downvote, awardable: awardable) end step 'The list should be sorted by "Least popular"' do diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index 733e80b7279..c6572cf386e 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -138,22 +138,6 @@ module SharedIssuable end end - step 'I should see "1 of 1" in the sidebar' do - expect_sidebar_content('1 of 1') - end - - step 'I should see "1 of 2" in the sidebar' do - expect_sidebar_content('1 of 2') - end - - step 'I should see "2 of 2" in the sidebar' do - expect_sidebar_content('2 of 2') - end - - step 'I should see "3 of 3" in the sidebar' do - expect_sidebar_content('3 of 3') - end - step 'I click link "Next" in the sidebar' do page.within '.issuable-sidebar' do click_link 'Next' diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 790a1869f73..66c138eb902 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -30,7 +30,7 @@ module API expose :identities, using: Entities::Identity expose :can_create_group?, as: :can_create_group expose :can_create_project?, as: :can_create_project - expose :two_factor_enabled + expose :two_factor_enabled?, as: :two_factor_enabled expose :external end @@ -171,15 +171,17 @@ module API expose :label_names, as: :labels expose :milestone, using: Entities::Milestone expose :assignee, :author, using: Entities::UserBasic + expose :subscribed do |issue, options| issue.subscribed?(options[:current_user]) end expose :user_notes_count + expose :upvotes, :downvotes end class MergeRequest < ProjectEntity expose :target_branch, :source_branch - expose :upvotes, :downvotes + expose :upvotes, :downvotes expose :author, :assignee, using: Entities::UserBasic expose :source_project_id, :target_project_id expose :label_names, as: :labels @@ -217,8 +219,8 @@ module API expose :system?, as: :system expose :noteable_id, :noteable_type # upvote? and downvote? are deprecated, always return false - expose :upvote?, as: :upvote - expose :downvote?, as: :downvote + expose(:upvote?) { |note| false } + expose(:downvote?) { |note| false } end class MRNote < Grape::Entity diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index 62161aadb9a..9cb14e95ebc 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -57,7 +57,7 @@ module API not_found! "File" unless blob content_type 'text/plain' - header *Gitlab::Workhorse.send_git_blob(repo, blob) + header(*Gitlab::Workhorse.send_git_blob(repo, blob)) end # Get a raw blob contents by blob sha @@ -83,7 +83,7 @@ module API env['api.format'] = :txt content_type blob.mime_type - header *Gitlab::Workhorse.send_git_blob(repo, blob) + header(*Gitlab::Workhorse.send_git_blob(repo, blob)) end # Get a an archive of the repository @@ -98,7 +98,7 @@ module API authorize! :download_code, user_project begin - header *Gitlab::Workhorse.send_git_archive(user_project, params[:sha], params[:format]) + header(*Gitlab::Workhorse.send_git_archive(user_project, params[:sha], params[:format])) rescue not_found!('File') end diff --git a/lib/award_emoji.rb b/lib/award_emoji.rb deleted file mode 100644 index b1aecc2e671..00000000000 --- a/lib/award_emoji.rb +++ /dev/null @@ -1,84 +0,0 @@ -class AwardEmoji - CATEGORIES = { - other: "Other", - objects: "Objects", - places: "Places", - travel_places: "Travel", - emoticons: "Emoticons", - objects_symbols: "Symbols", - nature: "Nature", - celebration: "Celebration", - people: "People", - activity: "Activity", - flags: "Flags", - food_drink: "Food" - }.with_indifferent_access - - CATEGORY_ALIASES = { - symbols: "objects_symbols", - foods: "food_drink", - travel: "travel_places" - }.with_indifferent_access - - def self.normilize_emoji_name(name) - aliases[name] || name - end - - def self.emoji_by_category - unless @emoji_by_category - @emoji_by_category = Hash.new { |h, key| h[key] = [] } - - emojis.each do |emoji_name, data| - data["name"] = emoji_name - - # Skip Fitzpatrick(tone) modifiers - next if data["category"] == "modifier" - - category = CATEGORY_ALIASES[data["category"]] || data["category"] - - @emoji_by_category[category] << data - end - - @emoji_by_category = @emoji_by_category.sort.to_h - end - - @emoji_by_category - end - - def self.emojis - @emojis ||= begin - json_path = File.join(Rails.root, 'fixtures', 'emojis', 'index.json' ) - JSON.parse(File.read(json_path)) - end - end - - def self.unicode - @unicode ||= emojis.map {|key, value| { key => emojis[key]["unicode"] } }.inject(:merge!) - end - - def self.aliases - @aliases ||= begin - json_path = File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json' ) - JSON.parse(File.read(json_path)) - end - end - - # Returns an Array of Emoji names and their asset URLs. - def self.urls - @urls ||= begin - path = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json') - prefix = Gitlab::Application.config.assets.prefix - digest = Gitlab::Application.config.assets.digest - - JSON.parse(File.read(path)).map do |hash| - if digest - fname = "#{hash['unicode']}-#{hash['digest']}" - else - fname = hash['unicode'] - end - - { name: hash['name'], path: "#{prefix}/#{fname}.png" } - end - end - end -end diff --git a/lib/backup/database.rb b/lib/backup/database.rb index 67b2a64bd10..22319ec6623 100644 --- a/lib/backup/database.rb +++ b/lib/backup/database.rb @@ -86,9 +86,9 @@ module Backup def report_success(success) if success - $progress.puts '[DONE]'.green + $progress.puts '[DONE]'.color(:green) else - $progress.puts '[FAILED]'.red + $progress.puts '[FAILED]'.color(:red) end end end diff --git a/lib/backup/manager.rb b/lib/backup/manager.rb index 660ca8c2923..9dd665441a0 100644 --- a/lib/backup/manager.rb +++ b/lib/backup/manager.rb @@ -27,9 +27,9 @@ module Backup # Set file permissions on open to prevent chmod races. tar_system_options = {out: [tar_file, 'w', Gitlab.config.backup.archive_permissions]} if Kernel.system('tar', '-cf', '-', *backup_contents, tar_system_options) - $progress.puts "done".green + $progress.puts "done".color(:green) else - puts "creating archive #{tar_file} failed".red + puts "creating archive #{tar_file} failed".color(:red) abort 'Backup failed' end @@ -43,7 +43,7 @@ module Backup connection_settings = Gitlab.config.backup.upload.connection if connection_settings.blank? - $progress.puts "skipped".yellow + $progress.puts "skipped".color(:yellow) return end @@ -53,9 +53,9 @@ module Backup if directory.files.create(key: tar_file, body: File.open(tar_file), public: false, multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size, encryption: Gitlab.config.backup.upload.encryption) - $progress.puts "done".green + $progress.puts "done".color(:green) else - puts "uploading backup to #{remote_directory} failed".red + puts "uploading backup to #{remote_directory} failed".color(:red) abort 'Backup failed' end end @@ -67,9 +67,9 @@ module Backup next unless File.exist?(File.join(Gitlab.config.backup.path, dir)) if FileUtils.rm_rf(File.join(Gitlab.config.backup.path, dir)) - $progress.puts "done".green + $progress.puts "done".color(:green) else - puts "deleting tmp directory '#{dir}' failed".red + puts "deleting tmp directory '#{dir}' failed".color(:red) abort 'Backup failed' end end @@ -95,9 +95,9 @@ module Backup end end - $progress.puts "done. (#{removed} removed)".green + $progress.puts "done. (#{removed} removed)".color(:green) else - $progress.puts "skipping".yellow + $progress.puts "skipping".color(:yellow) end end @@ -124,20 +124,20 @@ module Backup $progress.print "Unpacking backup ... " unless Kernel.system(*%W(tar -xf #{tar_file})) - puts "unpacking backup failed".red + puts "unpacking backup failed".color(:red) exit 1 else - $progress.puts "done".green + $progress.puts "done".color(:green) end ENV["VERSION"] = "#{settings[:db_version]}" if settings[:db_version].to_i > 0 # restoring mismatching backups can lead to unexpected problems if settings[:gitlab_version] != Gitlab::VERSION - puts "GitLab version mismatch:".red - puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".red - puts " Please switch to the following version and try again:".red - puts " version: #{settings[:gitlab_version]}".red + puts "GitLab version mismatch:".color(:red) + puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".color(:red) + puts " Please switch to the following version and try again:".color(:red) + puts " version: #{settings[:gitlab_version]}".color(:red) puts puts "Hint: git checkout v#{settings[:gitlab_version]}" exit 1 diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index a82a7e1f7bf..7b91215d50b 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -14,14 +14,14 @@ module Backup FileUtils.mkdir_p(File.join(backup_repos_path, project.namespace.path)) if project.namespace if project.empty_repo? - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else cmd = %W(tar -cf #{path_to_bundle(project)} -C #{path_to_repo(project)} .) output, status = Gitlab::Popen.popen(cmd) if status.zero? - $progress.puts "[DONE]".green + $progress.puts "[DONE]".color(:green) else - puts "[FAILED]".red + puts "[FAILED]".color(:red) puts "failed: #{cmd.join(' ')}" puts output abort 'Backup failed' @@ -33,14 +33,14 @@ module Backup if File.exists?(path_to_repo(wiki)) $progress.print " * #{wiki.path_with_namespace} ... " if wiki.repository.empty? - $progress.puts " [SKIPPED]".cyan + $progress.puts " [SKIPPED]".color(:cyan) else cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path_to_repo(wiki)} bundle create #{path_to_bundle(wiki)} --all) output, status = Gitlab::Popen.popen(cmd) if status.zero? - $progress.puts " [DONE]".green + $progress.puts " [DONE]".color(:green) else - puts " [FAILED]".red + puts " [FAILED]".color(:red) puts "failed: #{cmd.join(' ')}" abort 'Backup failed' end @@ -71,9 +71,9 @@ module Backup end if system(*cmd, silent) - $progress.puts "[DONE]".green + $progress.puts "[DONE]".color(:green) else - puts "[FAILED]".red + puts "[FAILED]".color(:red) puts "failed: #{cmd.join(' ')}" abort 'Restore failed' end @@ -90,21 +90,21 @@ module Backup cmd = %W(#{Gitlab.config.git.bin_path} clone --bare #{path_to_bundle(wiki)} #{path_to_repo(wiki)}) if system(*cmd, silent) - $progress.puts " [DONE]".green + $progress.puts " [DONE]".color(:green) else - puts " [FAILED]".red + puts " [FAILED]".color(:red) puts "failed: #{cmd.join(' ')}" abort 'Restore failed' end end end - $progress.print 'Put GitLab hooks in repositories dirs'.yellow + $progress.print 'Put GitLab hooks in repositories dirs'.color(:yellow) cmd = "#{Gitlab.config.gitlab_shell.path}/bin/create-hooks" if system(cmd) - $progress.puts " [DONE]".green + $progress.puts " [DONE]".color(:green) else - puts " [FAILED]".red + puts " [FAILED]".color(:red) puts "failed: #{cmd}" end diff --git a/lib/gitlab/award_emoji.rb b/lib/gitlab/award_emoji.rb new file mode 100644 index 00000000000..51b1df9ecbd --- /dev/null +++ b/lib/gitlab/award_emoji.rb @@ -0,0 +1,84 @@ +module Gitlab + class AwardEmoji + CATEGORIES = { + other: "Other", + objects: "Objects", + places: "Places", + travel_places: "Travel", + emoticons: "Emoticons", + objects_symbols: "Symbols", + nature: "Nature", + celebration: "Celebration", + people: "People", + activity: "Activity", + flags: "Flags", + food_drink: "Food" + }.with_indifferent_access + + CATEGORY_ALIASES = { + symbols: "objects_symbols", + foods: "food_drink", + travel: "travel_places" + }.with_indifferent_access + + def self.normalize_emoji_name(name) + aliases[name] || name + end + + def self.emoji_by_category + unless @emoji_by_category + @emoji_by_category = Hash.new { |h, key| h[key] = [] } + + emojis.each do |emoji_name, data| + data["name"] = emoji_name + + # Skip Fitzpatrick(tone) modifiers + next if data["category"] == "modifier" + + category = CATEGORY_ALIASES[data["category"]] || data["category"] + + @emoji_by_category[category] << data + end + + @emoji_by_category = @emoji_by_category.sort.to_h + end + + @emoji_by_category + end + + def self.emojis + @emojis ||= + begin + json_path = File.join(Rails.root, 'fixtures', 'emojis', 'index.json' ) + JSON.parse(File.read(json_path)) + end + end + + def self.aliases + @aliases ||= + begin + json_path = File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json' ) + JSON.parse(File.read(json_path)) + end + end + + # Returns an Array of Emoji names and their asset URLs. + def self.urls + @urls ||= begin + path = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json') + prefix = Gitlab::Application.config.assets.prefix + digest = Gitlab::Application.config.assets.digest + + JSON.parse(File.read(path)).map do |hash| + if digest + fname = "#{hash['unicode']}-#{hash['digest']}" + else + fname = hash['unicode'] + end + + { name: hash['name'], path: "#{prefix}/#{fname}.png" } + end + end + end + end +end diff --git a/lib/gitlab/key_fingerprint.rb b/lib/gitlab/key_fingerprint.rb index baf52ff750d..8684b4636ea 100644 --- a/lib/gitlab/key_fingerprint.rb +++ b/lib/gitlab/key_fingerprint.rb @@ -17,9 +17,9 @@ module Gitlab file.rewind cmd = [] - cmd.push *%W(ssh-keygen) - cmd.push *%W(-E md5) if explicit_fingerprint_algorithm? - cmd.push *%W(-lf #{file.path}) + cmd.push('ssh-keygen') + cmd.push('-E', 'md5') if explicit_fingerprint_algorithm? + cmd.push('-lf', file.path) cmd_output, cmd_status = popen(cmd, '/tmp') end diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb index aff7ccb157f..f9bb5775323 100644 --- a/lib/gitlab/ldap/config.rb +++ b/lib/gitlab/ldap/config.rb @@ -93,6 +93,7 @@ module Gitlab end protected + def base_config Gitlab.config.ldap end diff --git a/lib/gitlab/seeder.rb b/lib/gitlab/seeder.rb index 2ef0e982256..7cf506ebe64 100644 --- a/lib/gitlab/seeder.rb +++ b/lib/gitlab/seeder.rb @@ -5,7 +5,7 @@ module Gitlab SeedFu.quiet = true yield SeedFu.quiet = false - puts "\nOK".green + puts "\nOK".color(:green) end def self.by_user(user) diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake index 596eaca6d0d..9ee72fde92f 100644 --- a/lib/tasks/gitlab/backup.rake +++ b/lib/tasks/gitlab/backup.rake @@ -40,14 +40,14 @@ namespace :gitlab do removed. MSG ask_to_continue - puts 'Removing all tables. Press `Ctrl-C` within 5 seconds to abort'.yellow + puts 'Removing all tables. Press `Ctrl-C` within 5 seconds to abort'.color(:yellow) sleep(5) end # Drop all tables Load the schema to ensure we don't have any newer tables # hanging out from a failed upgrade - $progress.puts 'Cleaning the database ... '.blue + $progress.puts 'Cleaning the database ... '.color(:blue) Rake::Task['gitlab:db:drop_tables'].invoke - $progress.puts 'done'.green + $progress.puts 'done'.color(:green) Rake::Task['gitlab:backup:db:restore'].invoke end Rake::Task['gitlab:backup:repo:restore'].invoke unless backup.skipped?('repositories') @@ -63,141 +63,141 @@ namespace :gitlab do namespace :repo do task create: :environment do - $progress.puts "Dumping repositories ...".blue + $progress.puts "Dumping repositories ...".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("repositories") - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else Backup::Repository.new.dump - $progress.puts "done".green + $progress.puts "done".color(:green) end end task restore: :environment do - $progress.puts "Restoring repositories ...".blue + $progress.puts "Restoring repositories ...".color(:blue) Backup::Repository.new.restore - $progress.puts "done".green + $progress.puts "done".color(:green) end end namespace :db do task create: :environment do - $progress.puts "Dumping database ... ".blue + $progress.puts "Dumping database ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("db") - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else Backup::Database.new.dump - $progress.puts "done".green + $progress.puts "done".color(:green) end end task restore: :environment do - $progress.puts "Restoring database ... ".blue + $progress.puts "Restoring database ... ".color(:blue) Backup::Database.new.restore - $progress.puts "done".green + $progress.puts "done".color(:green) end end namespace :builds do task create: :environment do - $progress.puts "Dumping builds ... ".blue + $progress.puts "Dumping builds ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("builds") - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else Backup::Builds.new.dump - $progress.puts "done".green + $progress.puts "done".color(:green) end end task restore: :environment do - $progress.puts "Restoring builds ... ".blue + $progress.puts "Restoring builds ... ".color(:blue) Backup::Builds.new.restore - $progress.puts "done".green + $progress.puts "done".color(:green) end end namespace :uploads do task create: :environment do - $progress.puts "Dumping uploads ... ".blue + $progress.puts "Dumping uploads ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("uploads") - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else Backup::Uploads.new.dump - $progress.puts "done".green + $progress.puts "done".color(:green) end end task restore: :environment do - $progress.puts "Restoring uploads ... ".blue + $progress.puts "Restoring uploads ... ".color(:blue) Backup::Uploads.new.restore - $progress.puts "done".green + $progress.puts "done".color(:green) end end namespace :artifacts do task create: :environment do - $progress.puts "Dumping artifacts ... ".blue + $progress.puts "Dumping artifacts ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("artifacts") - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else Backup::Artifacts.new.dump - $progress.puts "done".green + $progress.puts "done".color(:green) end end task restore: :environment do - $progress.puts "Restoring artifacts ... ".blue + $progress.puts "Restoring artifacts ... ".color(:blue) Backup::Artifacts.new.restore - $progress.puts "done".green + $progress.puts "done".color(:green) end end namespace :lfs do task create: :environment do - $progress.puts "Dumping lfs objects ... ".blue + $progress.puts "Dumping lfs objects ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("lfs") - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else Backup::Lfs.new.dump - $progress.puts "done".green + $progress.puts "done".color(:green) end end task restore: :environment do - $progress.puts "Restoring lfs objects ... ".blue + $progress.puts "Restoring lfs objects ... ".color(:blue) Backup::Lfs.new.restore - $progress.puts "done".green + $progress.puts "done".color(:green) end end namespace :registry do task create: :environment do - $progress.puts "Dumping container registry images ... ".blue + $progress.puts "Dumping container registry images ... ".color(:blue) if Gitlab.config.registry.enabled if ENV["SKIP"] && ENV["SKIP"].include?("registry") - $progress.puts "[SKIPPED]".cyan + $progress.puts "[SKIPPED]".color(:cyan) else Backup::Registry.new.dump - $progress.puts "done".green + $progress.puts "done".color(:green) end else - $progress.puts "[DISABLED]".cyan + $progress.puts "[DISABLED]".color(:cyan) end end task restore: :environment do - $progress.puts "Restoring container registry images ... ".blue + $progress.puts "Restoring container registry images ... ".color(:blue) if Gitlab.config.registry.enabled Backup::Registry.new.restore - $progress.puts "done".green + $progress.puts "done".color(:green) else - $progress.puts "[DISABLED]".cyan + $progress.puts "[DISABLED]".color(:cyan) end end end diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index fad89c73762..12d6ac45fb6 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -50,14 +50,14 @@ namespace :gitlab do end if correct_options.all? - puts "yes".green + puts "yes".color(:green) else print "Trying to fix Git error automatically. ..." if auto_fix_git_config(options) - puts "Success".green + puts "Success".color(:green) else - puts "Failed".red + puts "Failed".color(:red) try_fixing_it( sudo_gitlab("\"#{Gitlab.config.git.bin_path}\" config --global core.autocrlf \"#{options["core.autocrlf"]}\"") ) @@ -74,9 +74,9 @@ namespace :gitlab do database_config_file = Rails.root.join("config", "database.yml") if File.exists?(database_config_file) - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Copy config/database.yml.<your db> to config/database.yml", "Check that the information in config/database.yml is correct" @@ -95,9 +95,9 @@ namespace :gitlab do gitlab_config_file = Rails.root.join("config", "gitlab.yml") if File.exists?(gitlab_config_file) - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Copy config/gitlab.yml.example to config/gitlab.yml", "Update config/gitlab.yml to match your setup" @@ -114,14 +114,14 @@ namespace :gitlab do gitlab_config_file = Rails.root.join("config", "gitlab.yml") unless File.exists?(gitlab_config_file) - puts "can't check because of previous errors".magenta + puts "can't check because of previous errors".color(:magenta) end # omniauth or ldap could have been deleted from the file unless Gitlab.config['git_host'] - puts "no".green + puts "no".color(:green) else - puts "yes".red + puts "yes".color(:red) try_fixing_it( "Backup your config/gitlab.yml", "Copy config/gitlab.yml.example to config/gitlab.yml", @@ -138,16 +138,16 @@ namespace :gitlab do print "Init script exists? ... " if omnibus_gitlab? - puts 'skipped (omnibus-gitlab has no init script)'.magenta + puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta) return end script_path = "/etc/init.d/gitlab" if File.exists?(script_path) - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Install the init script" ) @@ -162,7 +162,7 @@ namespace :gitlab do print "Init script up-to-date? ... " if omnibus_gitlab? - puts 'skipped (omnibus-gitlab has no init script)'.magenta + puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta) return end @@ -170,7 +170,7 @@ namespace :gitlab do script_path = "/etc/init.d/gitlab" unless File.exists?(script_path) - puts "can't check because of previous errors".magenta + puts "can't check because of previous errors".color(:magenta) return end @@ -178,9 +178,9 @@ namespace :gitlab do script_content = File.read(script_path) if recipe_content == script_content - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Redownload the init script" ) @@ -197,9 +197,9 @@ namespace :gitlab do migration_status, _ = Gitlab::Popen.popen(%W(bundle exec rake db:migrate:status)) unless migration_status =~ /down\s+\d{14}/ - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( sudo_gitlab("bundle exec rake db:migrate RAILS_ENV=production") ) @@ -210,13 +210,13 @@ namespace :gitlab do def check_orphaned_group_members print "Database contains orphaned GroupMembers? ... " if GroupMember.where("user_id not in (select id from users)").count > 0 - puts "yes".red + puts "yes".color(:red) try_fixing_it( "You can delete the orphaned records using something along the lines of:", sudo_gitlab("bundle exec rails runner -e production 'GroupMember.where(\"user_id NOT IN (SELECT id FROM users)\").delete_all'") ) else - puts "no".green + puts "no".color(:green) end end @@ -226,9 +226,9 @@ namespace :gitlab do log_path = Rails.root.join("log") if File.writable?(log_path) - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "sudo chown -R gitlab #{log_path}", "sudo chmod -R u+rwX #{log_path}" @@ -246,9 +246,9 @@ namespace :gitlab do tmp_path = Rails.root.join("tmp") if File.writable?(tmp_path) - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "sudo chown -R gitlab #{tmp_path}", "sudo chmod -R u+rwX #{tmp_path}" @@ -264,7 +264,7 @@ namespace :gitlab do print "Uploads directory setup correctly? ... " unless File.directory?(Rails.root.join('public/uploads')) - puts "no".red + puts "no".color(:red) try_fixing_it( "sudo -u #{gitlab_user} mkdir #{Rails.root}/public/uploads" ) @@ -280,16 +280,16 @@ namespace :gitlab do if File.stat(upload_path).mode == 040700 unless Dir.exists?(upload_path_tmp) - puts 'skipped (no tmp uploads folder yet)'.magenta + puts 'skipped (no tmp uploads folder yet)'.color(:magenta) return end # If tmp upload dir has incorrect permissions, assume others do as well # Verify drwx------ permissions if File.stat(upload_path_tmp).mode == 040700 && File.owned?(upload_path_tmp) - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "sudo chown -R #{gitlab_user} #{upload_path}", "sudo find #{upload_path} -type f -exec chmod 0644 {} \\;", @@ -301,7 +301,7 @@ namespace :gitlab do fix_and_rerun end else - puts "no".red + puts "no".color(:red) try_fixing_it( "sudo chmod 700 #{upload_path}" ) @@ -320,9 +320,9 @@ namespace :gitlab do redis_version = redis_version.try(:match, /redis-cli (\d+\.\d+\.\d+)/) if redis_version && (Gem::Version.new(redis_version[1]) > Gem::Version.new(min_redis_version)) - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Update your redis server to a version >= #{min_redis_version}" ) @@ -361,10 +361,10 @@ namespace :gitlab do repo_base_path = Gitlab.config.gitlab_shell.repos_path if File.exists?(repo_base_path) - puts "yes".green + puts "yes".color(:green) else - puts "no".red - puts "#{repo_base_path} is missing".red + puts "no".color(:red) + puts "#{repo_base_path} is missing".color(:red) try_fixing_it( "This should have been created when setting up GitLab Shell.", "Make sure it's set correctly in config/gitlab.yml", @@ -382,14 +382,14 @@ namespace :gitlab do repo_base_path = Gitlab.config.gitlab_shell.repos_path unless File.exists?(repo_base_path) - puts "can't check because of previous errors".magenta + puts "can't check because of previous errors".color(:magenta) return end unless File.symlink?(repo_base_path) - puts "no".green + puts "no".color(:green) else - puts "yes".red + puts "yes".color(:red) try_fixing_it( "Make sure it's set to the real directory in config/gitlab.yml" ) @@ -402,14 +402,14 @@ namespace :gitlab do repo_base_path = Gitlab.config.gitlab_shell.repos_path unless File.exists?(repo_base_path) - puts "can't check because of previous errors".magenta + puts "can't check because of previous errors".color(:magenta) return end if File.stat(repo_base_path).mode.to_s(8).ends_with?("2770") - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "sudo chmod -R ug+rwX,o-rwx #{repo_base_path}", "sudo chmod -R ug-s #{repo_base_path}", @@ -429,17 +429,17 @@ namespace :gitlab do repo_base_path = Gitlab.config.gitlab_shell.repos_path unless File.exists?(repo_base_path) - puts "can't check because of previous errors".magenta + puts "can't check because of previous errors".color(:magenta) return end uid = uid_for(gitlab_shell_ssh_user) gid = gid_for(gitlab_shell_owner_group) if File.stat(repo_base_path).uid == uid && File.stat(repo_base_path).gid == gid - puts "yes".green + puts "yes".color(:green) else - puts "no".red - puts " User id for #{gitlab_shell_ssh_user}: #{uid}. Groupd id for #{gitlab_shell_owner_group}: #{gid}".blue + puts "no".color(:red) + puts " User id for #{gitlab_shell_ssh_user}: #{uid}. Groupd id for #{gitlab_shell_owner_group}: #{gid}".color(:blue) try_fixing_it( "sudo chown -R #{gitlab_shell_ssh_user}:#{gitlab_shell_owner_group} #{repo_base_path}" ) @@ -456,7 +456,7 @@ namespace :gitlab do gitlab_shell_hooks_path = Gitlab.config.gitlab_shell.hooks_path unless Project.count > 0 - puts "can't check, you have no projects".magenta + puts "can't check, you have no projects".color(:magenta) return end puts "" @@ -466,12 +466,12 @@ namespace :gitlab do project_hook_directory = File.join(project.repository.path_to_repo, "hooks") if project.empty_repo? - puts "repository is empty".magenta + puts "repository is empty".color(:magenta) elsif File.directory?(project_hook_directory) && File.directory?(gitlab_shell_hooks_path) && (File.realpath(project_hook_directory) == File.realpath(gitlab_shell_hooks_path)) - puts 'ok'.green + puts 'ok'.color(:green) else - puts "wrong or missing hooks".red + puts "wrong or missing hooks".color(:red) try_fixing_it( sudo_gitlab("#{File.join(gitlab_shell_path, 'bin/create-hooks')}"), 'Check the hooks_path in config/gitlab.yml', @@ -491,9 +491,9 @@ namespace :gitlab do check_cmd = File.expand_path('bin/check', gitlab_shell_repo_base) puts "Running #{check_cmd}" if system(check_cmd, chdir: gitlab_shell_repo_base) - puts 'gitlab-shell self-check successful'.green + puts 'gitlab-shell self-check successful'.color(:green) else - puts 'gitlab-shell self-check failed'.red + puts 'gitlab-shell self-check failed'.color(:red) try_fixing_it( 'Make sure GitLab is running;', 'Check the gitlab-shell configuration file:', @@ -507,7 +507,7 @@ namespace :gitlab do print "projects have namespace: ... " unless Project.count > 0 - puts "can't check, you have no projects".magenta + puts "can't check, you have no projects".color(:magenta) return end puts "" @@ -516,9 +516,9 @@ namespace :gitlab do print sanitized_message(project) if project.namespace - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Migrate global projects" ) @@ -576,9 +576,9 @@ namespace :gitlab do print "Running? ... " if sidekiq_process_count > 0 - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( sudo_gitlab("RAILS_ENV=production bin/background_jobs start") ) @@ -596,9 +596,9 @@ namespace :gitlab do print 'Number of Sidekiq processes ... ' if process_count == 1 - puts '1'.green + puts '1'.color(:green) else - puts "#{process_count}".red + puts "#{process_count}".color(:red) try_fixing_it( 'sudo service gitlab stop', "sudo pkill -u #{gitlab_user} -f sidekiq", @@ -646,16 +646,16 @@ namespace :gitlab do print "Init.d configured correctly? ... " if omnibus_gitlab? - puts 'skipped (omnibus-gitlab has no init script)'.magenta + puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta) return end path = "/etc/default/gitlab" if File.exist?(path) && File.read(path).include?("mail_room_enabled=true") - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Enable mail_room in the init.d configuration." ) @@ -672,9 +672,9 @@ namespace :gitlab do path = Rails.root.join("Procfile") if File.exist?(path) && File.read(path) =~ /^mail_room:/ - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Enable mail_room in your Procfile." ) @@ -691,14 +691,14 @@ namespace :gitlab do path = "/etc/default/gitlab" unless File.exist?(path) && File.read(path).include?("mail_room_enabled=true") - puts "can't check because of previous errors".magenta + puts "can't check because of previous errors".color(:magenta) return end if mail_room_running? - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( sudo_gitlab("RAILS_ENV=production bin/mail_room start") ) @@ -729,9 +729,9 @@ namespace :gitlab do end if connected - puts "yes".green + puts "yes".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Check that the information in config/gitlab.yml is correct" ) @@ -799,7 +799,7 @@ namespace :gitlab do namespace :user do desc "GitLab | Check the integrity of a specific user's repositories" task :check_repos, [:username] => :environment do |t, args| - username = args[:username] || prompt("Check repository integrity for which username? ".blue) + username = args[:username] || prompt("Check repository integrity for which username? ".color(:blue)) user = User.find_by(username: username) if user repo_dirs = user.authorized_projects.map do |p| @@ -811,7 +811,7 @@ namespace :gitlab do repo_dirs.each { |repo_dir| check_repo_integrity(repo_dir) } else - puts "\nUser '#{username}' not found".red + puts "\nUser '#{username}' not found".color(:red) end end end @@ -820,13 +820,13 @@ namespace :gitlab do ########################## def fix_and_rerun - puts " Please #{"fix the error above"} and rerun the checks.".red + puts " Please #{"fix the error above"} and rerun the checks.".color(:red) end def for_more_information(*sources) sources = sources.shift if sources.first.is_a?(Array) - puts " For more information see:".blue + puts " For more information see:".color(:blue) sources.each do |source| puts " #{source}" end @@ -834,7 +834,7 @@ namespace :gitlab do def finished_checking(component) puts "" - puts "Checking #{component.yellow} ... #{"Finished".green}" + puts "Checking #{component.color(:yellow)} ... #{"Finished".color(:green)}" puts "" end @@ -855,14 +855,14 @@ namespace :gitlab do end def start_checking(component) - puts "Checking #{component.yellow} ..." + puts "Checking #{component.color(:yellow)} ..." puts "" end def try_fixing_it(*steps) steps = steps.shift if steps.first.is_a?(Array) - puts " Try fixing it:".blue + puts " Try fixing it:".color(:blue) steps.each do |step| puts " #{step}" end @@ -874,9 +874,9 @@ namespace :gitlab do print "GitLab Shell version >= #{required_version} ? ... " if current_version.valid? && required_version <= current_version - puts "OK (#{current_version})".green + puts "OK (#{current_version})".color(:green) else - puts "FAIL. Please update gitlab-shell to #{required_version} from #{current_version}".red + puts "FAIL. Please update gitlab-shell to #{required_version} from #{current_version}".color(:red) end end @@ -887,9 +887,9 @@ namespace :gitlab do print "Ruby version >= #{required_version} ? ... " if current_version.valid? && required_version <= current_version - puts "yes (#{current_version})".green + puts "yes (#{current_version})".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Update your ruby to a version >= #{required_version} from #{current_version}" ) @@ -905,9 +905,9 @@ namespace :gitlab do print "Git version >= #{required_version} ? ... " if current_version.valid? && required_version <= current_version - puts "yes (#{current_version})".green + puts "yes (#{current_version})".color(:green) else - puts "no".red + puts "no".color(:red) try_fixing_it( "Update your git to a version >= #{required_version} from #{current_version}" ) @@ -925,9 +925,9 @@ namespace :gitlab do def sanitized_message(project) if should_sanitize? - "#{project.namespace_id.to_s.yellow}/#{project.id.to_s.yellow} ... " + "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... " else - "#{project.name_with_namespace.yellow} ... " + "#{project.name_with_namespace.color(:yellow)} ... " end end @@ -940,7 +940,7 @@ namespace :gitlab do end def check_repo_integrity(repo_dir) - puts "\nChecking repo at #{repo_dir.yellow}" + puts "\nChecking repo at #{repo_dir.color(:yellow)}" git_fsck(repo_dir) check_config_lock(repo_dir) @@ -948,25 +948,25 @@ namespace :gitlab do end def git_fsck(repo_dir) - puts "Running `git fsck`".yellow + puts "Running `git fsck`".color(:yellow) system(*%W(#{Gitlab.config.git.bin_path} fsck), chdir: repo_dir) end def check_config_lock(repo_dir) config_exists = File.exist?(File.join(repo_dir,'config.lock')) - config_output = config_exists ? 'yes'.red : 'no'.green - puts "'config.lock' file exists?".yellow + " ... #{config_output}" + config_output = config_exists ? 'yes'.color(:red) : 'no'.color(:green) + puts "'config.lock' file exists?".color(:yellow) + " ... #{config_output}" end def check_ref_locks(repo_dir) lock_files = Dir.glob(File.join(repo_dir,'refs/heads/*.lock')) if lock_files.present? - puts "Ref lock files exist:".red + puts "Ref lock files exist:".color(:red) lock_files.each do |lock_file| puts " #{lock_file}" end else - puts "No ref lock files exist".green + puts "No ref lock files exist".color(:green) end end end diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index 9f5852ac613..ab0028d6603 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -10,7 +10,7 @@ namespace :gitlab do git_base_path = Gitlab.config.gitlab_shell.repos_path all_dirs = Dir.glob(git_base_path + '/*') - puts git_base_path.yellow + puts git_base_path.color(:yellow) puts "Looking for directories to remove... " all_dirs.reject! do |dir| @@ -29,17 +29,17 @@ namespace :gitlab do if remove_flag if FileUtils.rm_rf dir_path - puts "Removed...#{dir_path}".red + puts "Removed...#{dir_path}".color(:red) else - puts "Cannot remove #{dir_path}".red + puts "Cannot remove #{dir_path}".color(:red) end else - puts "Can be removed: #{dir_path}".red + puts "Can be removed: #{dir_path}".color(:red) end end unless remove_flag - puts "To cleanup this directories run this command with REMOVE=true".yellow + puts "To cleanup this directories run this command with REMOVE=true".color(:yellow) end end @@ -75,19 +75,19 @@ namespace :gitlab do next unless user.ldap_user? print "#{user.name} (#{user.ldap_identity.extern_uid}) ..." if Gitlab::LDAP::Access.allowed?(user) - puts " [OK]".green + puts " [OK]".color(:green) else if block_flag user.block! unless user.blocked? - puts " [BLOCKED]".red + puts " [BLOCKED]".color(:red) else - puts " [NOT IN LDAP]".yellow + puts " [NOT IN LDAP]".color(:yellow) end end end unless block_flag - puts "To block these users run this command with BLOCK=true".yellow + puts "To block these users run this command with BLOCK=true".color(:yellow) end end end diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 86f5d65f128..86584e91093 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -3,22 +3,22 @@ namespace :gitlab do desc 'GitLab | Manually insert schema migration version' task :mark_migration_complete, [:version] => :environment do |_, args| unless args[:version] - puts "Must specify a migration version as an argument".red + puts "Must specify a migration version as an argument".color(:red) exit 1 end version = args[:version].to_i if version == 0 - puts "Version '#{args[:version]}' must be a non-zero integer".red + puts "Version '#{args[:version]}' must be a non-zero integer".color(:red) exit 1 end sql = "INSERT INTO schema_migrations (version) VALUES (#{version})" begin ActiveRecord::Base.connection.execute(sql) - puts "Successfully marked '#{version}' as complete".green + puts "Successfully marked '#{version}' as complete".color(:green) rescue ActiveRecord::RecordNotUnique - puts "Migration version '#{version}' is already marked complete".yellow + puts "Migration version '#{version}' is already marked complete".color(:yellow) end end diff --git a/lib/tasks/gitlab/git.rake b/lib/tasks/gitlab/git.rake index 65ee430d550..f9834a4dae8 100644 --- a/lib/tasks/gitlab/git.rake +++ b/lib/tasks/gitlab/git.rake @@ -5,7 +5,7 @@ namespace :gitlab do task repack: :environment do failures = perform_git_cmd(%W(git repack -a --quiet), "Repacking repo") if failures.empty? - puts "Done".green + puts "Done".color(:green) else output_failures(failures) end @@ -15,7 +15,7 @@ namespace :gitlab do task gc: :environment do failures = perform_git_cmd(%W(git gc --auto --quiet), "Garbage Collecting") if failures.empty? - puts "Done".green + puts "Done".color(:green) else output_failures(failures) end @@ -25,7 +25,7 @@ namespace :gitlab do task prune: :environment do failures = perform_git_cmd(%W(git prune), "Git Prune") if failures.empty? - puts "Done".green + puts "Done".color(:green) else output_failures(failures) end @@ -47,7 +47,7 @@ namespace :gitlab do end def output_failures(failures) - puts "The following repositories reported errors:".red + puts "The following repositories reported errors:".color(:red) failures.each { |f| puts "- #{f}" } end diff --git a/lib/tasks/gitlab/import.rake b/lib/tasks/gitlab/import.rake index 1c04f47f08f..4753f00c26a 100644 --- a/lib/tasks/gitlab/import.rake +++ b/lib/tasks/gitlab/import.rake @@ -23,7 +23,7 @@ namespace :gitlab do group_name, name = File.split(path) group_name = nil if group_name == '.' - puts "Processing #{repo_path}".yellow + puts "Processing #{repo_path}".color(:yellow) if path.end_with?('.wiki') puts " * Skipping wiki repo" @@ -51,9 +51,9 @@ namespace :gitlab do group.path = group_name group.owner = user if group.save - puts " * Created Group #{group.name} (#{group.id})".green + puts " * Created Group #{group.name} (#{group.id})".color(:green) else - puts " * Failed trying to create group #{group.name}".red + puts " * Failed trying to create group #{group.name}".color(:red) end end # set project group @@ -63,17 +63,17 @@ namespace :gitlab do project = Projects::CreateService.new(user, project_params).execute if project.persisted? - puts " * Created #{project.name} (#{repo_path})".green + puts " * Created #{project.name} (#{repo_path})".color(:green) project.update_repository_size project.update_commit_count else - puts " * Failed trying to create #{project.name} (#{repo_path})".red - puts " Errors: #{project.errors.messages}".red + puts " * Failed trying to create #{project.name} (#{repo_path})".color(:red) + puts " Errors: #{project.errors.messages}".color(:red) end end end - puts "Done!".green + puts "Done!".color(:green) end end end diff --git a/lib/tasks/gitlab/info.rake b/lib/tasks/gitlab/info.rake index d6883a563ee..352b566df24 100644 --- a/lib/tasks/gitlab/info.rake +++ b/lib/tasks/gitlab/info.rake @@ -15,15 +15,15 @@ namespace :gitlab do rake_version = run_and_match(%W(rake --version), /[\d\.]+/).try(:to_s) puts "" - puts "System information".yellow - puts "System:\t\t#{os_name || "unknown".red}" + puts "System information".color(:yellow) + puts "System:\t\t#{os_name || "unknown".color(:red)}" puts "Current User:\t#{run(%W(whoami))}" - puts "Using RVM:\t#{rvm_version.present? ? "yes".green : "no"}" + puts "Using RVM:\t#{rvm_version.present? ? "yes".color(:green) : "no"}" puts "RVM Version:\t#{rvm_version}" if rvm_version.present? - puts "Ruby Version:\t#{ruby_version || "unknown".red}" - puts "Gem Version:\t#{gem_version || "unknown".red}" - puts "Bundler Version:#{bunder_version || "unknown".red}" - puts "Rake Version:\t#{rake_version || "unknown".red}" + puts "Ruby Version:\t#{ruby_version || "unknown".color(:red)}" + puts "Gem Version:\t#{gem_version || "unknown".color(:red)}" + puts "Bundler Version:#{bunder_version || "unknown".color(:red)}" + puts "Rake Version:\t#{rake_version || "unknown".color(:red)}" puts "Sidekiq Version:#{Sidekiq::VERSION}" @@ -39,7 +39,7 @@ namespace :gitlab do omniauth_providers.map! { |provider| provider['name'] } puts "" - puts "GitLab information".yellow + puts "GitLab information".color(:yellow) puts "Version:\t#{Gitlab::VERSION}" puts "Revision:\t#{Gitlab::REVISION}" puts "Directory:\t#{Rails.root}" @@ -47,9 +47,9 @@ namespace :gitlab do puts "URL:\t\t#{Gitlab.config.gitlab.url}" puts "HTTP Clone URL:\t#{http_clone_url}" puts "SSH Clone URL:\t#{ssh_clone_url}" - puts "Using LDAP:\t#{Gitlab.config.ldap.enabled ? "yes".green : "no"}" - puts "Using Omniauth:\t#{Gitlab.config.omniauth.enabled ? "yes".green : "no"}" - puts "Omniauth Providers: #{omniauth_providers.map(&:magenta).join(', ')}" if Gitlab.config.omniauth.enabled + puts "Using LDAP:\t#{Gitlab.config.ldap.enabled ? "yes".color(:green) : "no"}" + puts "Using Omniauth:\t#{Gitlab.config.omniauth.enabled ? "yes".color(:green) : "no"}" + puts "Omniauth Providers: #{omniauth_providers.join(', ')}" if Gitlab.config.omniauth.enabled @@ -60,8 +60,8 @@ namespace :gitlab do end puts "" - puts "GitLab Shell".yellow - puts "Version:\t#{gitlab_shell_version || "unknown".red}" + puts "GitLab Shell".color(:yellow) + puts "Version:\t#{gitlab_shell_version || "unknown".color(:red)}" puts "Repositories:\t#{Gitlab.config.gitlab_shell.repos_path}" puts "Hooks:\t\t#{Gitlab.config.gitlab_shell.hooks_path}" puts "Git:\t\t#{Gitlab.config.git.bin_path}" diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index dd61632e557..b1648a4602a 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -118,12 +118,12 @@ namespace :gitlab do puts "" unless $?.success? - puts "Failed to add keys...".red + puts "Failed to add keys...".color(:red) exit 1 end rescue Gitlab::TaskAbortedByUserError - puts "Quitting...".red + puts "Quitting...".color(:red) exit 1 end diff --git a/lib/tasks/gitlab/task_helpers.rake b/lib/tasks/gitlab/task_helpers.rake index d33b5b31e18..d0c019044b7 100644 --- a/lib/tasks/gitlab/task_helpers.rake +++ b/lib/tasks/gitlab/task_helpers.rake @@ -2,7 +2,7 @@ module Gitlab class TaskAbortedByUserError < StandardError; end end -String.disable_colorization = true unless STDOUT.isatty +require 'rainbow/ext/string' # Prevent StateMachine warnings from outputting during a cron task StateMachines::Machine.ignore_method_conflicts = true if ENV['CRON'] @@ -14,7 +14,7 @@ namespace :gitlab do # Returns "yes" the user chose to continue # Raises Gitlab::TaskAbortedByUserError if the user chose *not* to continue def ask_to_continue - answer = prompt("Do you want to continue (yes/no)? ".blue, %w{yes no}) + answer = prompt("Do you want to continue (yes/no)? ".color(:blue), %w{yes no}) raise Gitlab::TaskAbortedByUserError unless answer == "yes" end @@ -98,10 +98,10 @@ namespace :gitlab do gitlab_user = Gitlab.config.gitlab.user current_user = run(%W(whoami)).chomp unless current_user == gitlab_user - puts " Warning ".colorize(:black).on_yellow - puts " You are running as user #{current_user.magenta}, we hope you know what you are doing." + puts " Warning ".color(:black).background(:yellow) + puts " You are running as user #{current_user.color(:magenta)}, we hope you know what you are doing." puts " Things may work\/fail for the wrong reasons." - puts " For correct results you should run this as user #{gitlab_user.magenta}." + puts " For correct results you should run this as user #{gitlab_user.color(:magenta)}." puts "" end @warned_user_not_gitlab = true diff --git a/lib/tasks/gitlab/two_factor.rake b/lib/tasks/gitlab/two_factor.rake index 9196677a017..fc0ccc726ed 100644 --- a/lib/tasks/gitlab/two_factor.rake +++ b/lib/tasks/gitlab/two_factor.rake @@ -6,17 +6,17 @@ namespace :gitlab do count = scope.count if count > 0 - puts "This will disable 2FA for #{count.to_s.red} users..." + puts "This will disable 2FA for #{count.to_s.color(:red)} users..." begin ask_to_continue scope.find_each(&:disable_two_factor!) - puts "Successfully disabled 2FA for #{count} users.".green + puts "Successfully disabled 2FA for #{count} users.".color(:green) rescue Gitlab::TaskAbortedByUserError - puts "Quitting...".red + puts "Quitting...".color(:red) end else - puts "There are currently no users with 2FA enabled.".yellow + puts "There are currently no users with 2FA enabled.".color(:yellow) end end end diff --git a/lib/tasks/gitlab/update_commit_count.rake b/lib/tasks/gitlab/update_commit_count.rake index 9b636f12d9f..3bd10b0208b 100644 --- a/lib/tasks/gitlab/update_commit_count.rake +++ b/lib/tasks/gitlab/update_commit_count.rake @@ -6,15 +6,15 @@ namespace :gitlab do ask_to_continue unless ENV['force'] == 'yes' projects.find_each(batch_size: 100) do |project| - print "#{project.name_with_namespace.yellow} ... " + print "#{project.name_with_namespace.color(:yellow)} ... " unless project.repo_exists? - puts "skipping, because the repo is empty".magenta + puts "skipping, because the repo is empty".color(:magenta) next end project.update_commit_count - puts project.commit_count.to_s.green + puts project.commit_count.to_s.color(:green) end end end diff --git a/lib/tasks/gitlab/update_gitignore.rake b/lib/tasks/gitlab/update_gitignore.rake index 84aa312002b..4fd48cccb1d 100644 --- a/lib/tasks/gitlab/update_gitignore.rake +++ b/lib/tasks/gitlab/update_gitignore.rake @@ -2,14 +2,14 @@ namespace :gitlab do desc "GitLab | Update gitignore" task :update_gitignore do unless clone_gitignores - puts "Cloning the gitignores failed".red + puts "Cloning the gitignores failed".color(:red) return end remove_unneeded_files(gitignore_directory) remove_unneeded_files(global_directory) - puts "Done".green + puts "Done".color(:green) end def clone_gitignores diff --git a/lib/tasks/gitlab/web_hook.rake b/lib/tasks/gitlab/web_hook.rake index cc0f668474e..f467cc0ee29 100644 --- a/lib/tasks/gitlab/web_hook.rake +++ b/lib/tasks/gitlab/web_hook.rake @@ -12,9 +12,9 @@ namespace :gitlab do print "- #{project.name} ... " web_hook = project.hooks.new(url: web_hook_url) if web_hook.save - puts "added".green + puts "added".color(:green) else - print "failed".red + print "failed".color(:red) puts " [#{web_hook.errors.full_messages.to_sentence}]" end end @@ -57,7 +57,7 @@ namespace :gitlab do if namespace Project.in_namespace(namespace.id) else - puts "Namespace not found: #{namespace_path}".red + puts "Namespace not found: #{namespace_path}".color(:red) exit 2 end end diff --git a/lib/tasks/migrate/migrate_iids.rake b/lib/tasks/migrate/migrate_iids.rake index d258c6fd08d..4f2486157b7 100644 --- a/lib/tasks/migrate/migrate_iids.rake +++ b/lib/tasks/migrate/migrate_iids.rake @@ -1,6 +1,6 @@ desc "GitLab | Build internal ids for issues and merge requests" task migrate_iids: :environment do - puts 'Issues'.yellow + puts 'Issues'.color(:yellow) Issue.where(iid: nil).find_each(batch_size: 100) do |issue| begin issue.set_iid @@ -15,7 +15,7 @@ task migrate_iids: :environment do end puts 'done' - puts 'Merge Requests'.yellow + puts 'Merge Requests'.color(:yellow) MergeRequest.where(iid: nil).find_each(batch_size: 100) do |mr| begin mr.set_iid @@ -30,7 +30,7 @@ task migrate_iids: :environment do end puts 'done' - puts 'Milestones'.yellow + puts 'Milestones'.color(:yellow) Milestone.where(iid: nil).find_each(batch_size: 100) do |m| begin m.set_iid diff --git a/lib/tasks/spinach.rake b/lib/tasks/spinach.rake index 01d23b89bb7..da255f5464b 100644 --- a/lib/tasks/spinach.rake +++ b/lib/tasks/spinach.rake @@ -52,7 +52,7 @@ def run_spinach_tests(tags) tests = File.foreach('tmp/spinach-rerun.txt').map(&:chomp) puts '' - puts "Spinach tests for #{tags}: Retrying tests... #{tests}".red + puts "Spinach tests for #{tags}: Retrying tests... #{tests}".color(:red) puts '' sleep(3) success = run_spinach_command(tests) diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 465531b2b36..cd98fecd0c7 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -31,9 +31,9 @@ describe GroupsController do let(:issue_2) { create(:issue, project: project) } before do - create_list(:upvote_note, 3, project: project, noteable: issue_2) - create_list(:upvote_note, 2, project: project, noteable: issue_1) - create_list(:downvote_note, 2, project: project, noteable: issue_2) + create_list(:award_emoji, 3, awardable: issue_2) + create_list(:award_emoji, 2, awardable: issue_1) + create_list(:award_emoji, 2, :downvote, awardable: issue_2,) sign_in(user) end @@ -56,9 +56,9 @@ describe GroupsController do let(:merge_request_2) { create(:merge_request, :simple, source_project: project) } before do - create_list(:upvote_note, 3, project: project, noteable: merge_request_2) - create_list(:upvote_note, 2, project: project, noteable: merge_request_1) - create_list(:downvote_note, 2, project: project, noteable: merge_request_2) + create_list(:award_emoji, 3, awardable: merge_request_2) + create_list(:award_emoji, 2, awardable: merge_request_1) + create_list(:award_emoji, 2, :downvote, awardable: merge_request_2) sign_in(user) end diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 4fb1473c2d2..d08d0018b35 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -8,21 +8,21 @@ describe Profiles::TwoFactorAuthsController do allow(subject).to receive(:current_user).and_return(user) end - describe 'GET new' do + describe 'GET show' do let(:user) { create(:user) } it 'generates otp_secret for user' do expect(User).to receive(:generate_otp_secret).with(32).and_return('secret').once - get :new - get :new # Second hit shouldn't re-generate it + get :show + get :show # Second hit shouldn't re-generate it end it 'assigns qr_code' do code = double('qr code') expect(subject).to receive(:build_qr_code).and_return(code) - get :new + get :show expect(assigns[:qr_code]).to eq code end end @@ -40,7 +40,7 @@ describe Profiles::TwoFactorAuthsController do expect(user).to receive(:validate_and_consume_otp!).with(pin).and_return(true) end - it 'sets two_factor_enabled' do + it 'enables 2fa for the user' do go user.reload @@ -79,9 +79,9 @@ describe Profiles::TwoFactorAuthsController do expect(assigns[:qr_code]).to eq code end - it 'renders new' do + it 'renders show' do go - expect(response).to render_template(:new) + expect(response).to render_template(:show) end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index c469480b086..78be7e3dc35 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -250,4 +250,20 @@ describe Projects::IssuesController do end end end + + describe 'POST #toggle_award_emoji' do + before do + sign_in(user) + project.team << [user, :developer] + end + + it "toggles the award emoji" do + expect do + post(:toggle_award_emoji, namespace_id: project.namespace.path, + project_id: project.path, id: issue.iid, name: "thumbsup") + end.to change { issue.award_emoji.count }.by(1) + + expect(response.status).to eq(200) + end + end end diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb new file mode 100644 index 00000000000..00bc38b6071 --- /dev/null +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -0,0 +1,36 @@ +require('spec_helper') + +describe Projects::NotesController do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project) } + let(:note) { create(:note, noteable: issue, project: project) } + + describe 'POST #toggle_award_emoji' do + before do + sign_in(user) + project.team << [user, :developer] + end + + it "toggles the award emoji" do + expect do + post(:toggle_award_emoji, namespace_id: project.namespace.path, + project_id: project.path, id: note.id, name: "thumbsup") + end.to change { note.award_emoji.count }.by(1) + + expect(response.status).to eq(200) + end + + it "removes the already awarded emoji" do + post(:toggle_award_emoji, namespace_id: project.namespace.path, + project_id: project.path, id: note.id, name: "thumbsup") + + expect do + post(:toggle_award_emoji, namespace_id: project.namespace.path, + project_id: project.path, id: note.id, name: "thumbsup") + end.to change { AwardEmoji.count }.by(-1) + + expect(response.status).to eq(200) + end + end +end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 5dc8724fb50..4e9bfb0c69b 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -25,10 +25,15 @@ describe SessionsController do expect(response).to set_flash.to /Signed in successfully/ expect(subject.current_user). to eq user end + + it "creates an audit log record" do + expect { post(:create, user: { login: user.username, password: user.password }) }.to change { SecurityEvent.count }.by(1) + expect(SecurityEvent.last.details[:with]).to eq("standard") + end end end - context 'when using two-factor authentication' do + context 'when using two-factor authentication via OTP' do let(:user) { create(:user, :two_factor) } def authenticate_2fa(user_params) @@ -117,6 +122,25 @@ describe SessionsController do end end end + + it "creates an audit log record" do + expect { authenticate_2fa(login: user.username, otp_attempt: user.current_otp) }.to change { SecurityEvent.count }.by(1) + expect(SecurityEvent.last.details[:with]).to eq("two-factor") + end + end + + context 'when using two-factor authentication via U2F device' do + let(:user) { create(:user, :two_factor) } + + def authenticate_2fa_u2f(user_params) + post(:create, { user: user_params }, { otp_user_id: user.id }) + end + + it "creates an audit log record" do + allow(U2fRegistration).to receive(:authenticate).and_return(true) + expect { authenticate_2fa_u2f(login: user.username, device_response: "{}") }.to change { SecurityEvent.count }.by(1) + expect(SecurityEvent.last.details[:with]).to eq("two-factor-via-u2f-device") + end end end end diff --git a/spec/factories/award_emoji.rb b/spec/factories/award_emoji.rb new file mode 100644 index 00000000000..4b858df52c9 --- /dev/null +++ b/spec/factories/award_emoji.rb @@ -0,0 +1,12 @@ +FactoryGirl.define do + factory :award_emoji do + name "thumbsup" + user + awardable factory: :issue + + trait :upvote + trait :downvote do + name "thumbsdown" + end + end +end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index c32e205ee69..696cf276e57 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -16,8 +16,6 @@ FactoryGirl.define do factory :note_on_merge_request_diff, traits: [:on_merge_request, :on_diff], class: LegacyDiffNote factory :note_on_project_snippet, traits: [:on_project_snippet] factory :system_note, traits: [:system] - factory :downvote_note, traits: [:award, :downvote] - factory :upvote_note, traits: [:award, :upvote] trait :on_commit do noteable nil @@ -46,10 +44,6 @@ FactoryGirl.define do system true end - trait :award do - is_award true - end - trait :downvote do note "thumbsdown" end diff --git a/spec/factories/u2f_registrations.rb b/spec/factories/u2f_registrations.rb new file mode 100644 index 00000000000..df92b079581 --- /dev/null +++ b/spec/factories/u2f_registrations.rb @@ -0,0 +1,8 @@ +FactoryGirl.define do + factory :u2f_registration do + certificate { FFaker::BaconIpsum.characters(728) } + key_handle { FFaker::BaconIpsum.characters(86) } + public_key { FFaker::BaconIpsum.characters(88) } + counter 0 + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index a9b2148bd2a..c6f7869516e 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -15,14 +15,26 @@ FactoryGirl.define do end trait :two_factor do + two_factor_via_otp + end + + trait :two_factor_via_otp do before(:create) do |user| - user.two_factor_enabled = true + user.otp_required_for_login = true user.otp_secret = User.generate_otp_secret(32) user.otp_grace_period_started_at = Time.now user.generate_otp_backup_codes! end end + trait :two_factor_via_u2f do + transient { registrations_count 5 } + + after(:create) do |user, evaluator| + create_list(:u2f_registration, evaluator.registrations_count, user: user) + end + end + factory :omniauth_user do transient do extern_uid '123456' diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 96621843b30..b72ad405479 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -19,7 +19,7 @@ describe "Admin::Users", feature: true do describe 'Two-factor Authentication filters' do it 'counts users who have enabled 2FA' do - create(:user, two_factor_enabled: true) + create(:user, :two_factor) visit admin_users_path @@ -29,7 +29,7 @@ describe "Admin::Users", feature: true do end it 'filters by users who have enabled 2FA' do - user = create(:user, two_factor_enabled: true) + user = create(:user, :two_factor) visit admin_users_path click_link '2FA Enabled' @@ -38,7 +38,7 @@ describe "Admin::Users", feature: true do end it 'counts users who have not enabled 2FA' do - create(:user, two_factor_enabled: false) + create(:user) visit admin_users_path @@ -48,7 +48,7 @@ describe "Admin::Users", feature: true do end it 'filters by users who have not enabled 2FA' do - user = create(:user, two_factor_enabled: false) + user = create(:user) visit admin_users_path click_link '2FA Disabled' @@ -173,7 +173,7 @@ describe "Admin::Users", feature: true do describe 'Two-factor Authentication status' do it 'shows when enabled' do - @user.update_attribute(:two_factor_enabled, true) + @user.update_attribute(:otp_required_for_login, true) visit admin_user_path(@user) diff --git a/spec/features/builds_spec.rb b/spec/features/builds_spec.rb index 7a05d30e8b5..e268d76755f 100644 --- a/spec/features/builds_spec.rb +++ b/spec/features/builds_spec.rb @@ -7,6 +7,7 @@ describe "Builds" do login_as(:user) @commit = FactoryGirl.create :ci_commit @build = FactoryGirl.create :ci_build, commit: @commit + @build2 = FactoryGirl.create :ci_build @project = @commit.project @project.team << [@user, :developer] end @@ -66,13 +67,24 @@ describe "Builds" do end describe "GET /:project/builds/:id" do - before do - visit namespace_project_build_path(@project.namespace, @project, @build) + context "Build from project" do + before do + visit namespace_project_build_path(@project.namespace, @project, @build) + end + + it { expect(page.status_code).to eq(200) } + it { expect(page).to have_content @commit.sha[0..7] } + it { expect(page).to have_content @commit.git_commit_message } + it { expect(page).to have_content @commit.git_author_name } end - it { expect(page).to have_content @commit.sha[0..7] } - it { expect(page).to have_content @commit.git_commit_message } - it { expect(page).to have_content @commit.git_author_name } + context "Build from other project" do + before do + visit namespace_project_build_path(@project.namespace, @project, @build2) + end + + it { expect(page.status_code).to eq(404) } + end context "Download artifacts" do before do @@ -103,51 +115,143 @@ describe "Builds" do end describe "POST /:project/builds/:id/cancel" do - before do - @build.run! - visit namespace_project_build_path(@project.namespace, @project, @build) - click_link "Cancel" + context "Build from project" do + before do + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + click_link "Cancel" + end + + it { expect(page.status_code).to eq(200) } + it { expect(page).to have_content 'canceled' } + it { expect(page).to have_content 'Retry' } end - it { expect(page).to have_content 'canceled' } - it { expect(page).to have_content 'Retry' } + context "Build from other project" do + before do + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + page.driver.post(cancel_namespace_project_build_path(@project.namespace, @project, @build2)) + end + + it { expect(page.status_code).to eq(404) } + end end describe "POST /:project/builds/:id/retry" do - before do - @build.run! - visit namespace_project_build_path(@project.namespace, @project, @build) - click_link "Cancel" - click_link 'Retry' + context "Build from project" do + before do + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + click_link 'Cancel' + click_link 'Retry' + end + + it { expect(page.status_code).to eq(200) } + it { expect(page).to have_content 'pending' } + it { expect(page).to have_content 'Cancel' } end - it { expect(page).to have_content 'pending' } - it { expect(page).to have_content 'Cancel' } + context "Build from other project" do + before do + @build.run! + visit namespace_project_build_path(@project.namespace, @project, @build) + click_link 'Cancel' + page.driver.post(retry_namespace_project_build_path(@project.namespace, @project, @build2)) + end + + it { expect(page.status_code).to eq(404) } + end end describe "GET /:project/builds/:id/download" do - before do - @build.update_attributes(artifacts_file: artifacts_file) - visit namespace_project_build_path(@project.namespace, @project, @build) - page.within('.artifacts') { click_link 'Download' } + context "Build from project" do + before do + @build.update_attributes(artifacts_file: artifacts_file) + visit namespace_project_build_path(@project.namespace, @project, @build) + page.within('.artifacts') { click_link 'Download' } + end + + it { expect(page.status_code).to eq(200) } + it { expect(page.response_headers['Content-Type']).to eq(artifacts_file.content_type) } end - it { expect(page.response_headers['Content-Type']).to eq(artifacts_file.content_type) } + context "Build from other project" do + before do + @build2.update_attributes(artifacts_file: artifacts_file) + visit download_namespace_project_build_artifacts_path(@project.namespace, @project, @build2) + end + + it { expect(page.status_code).to eq(404) } + end end describe "GET /:project/builds/:id/raw" do - before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') - @build.run! - @build.trace = 'BUILD TRACE' - visit namespace_project_build_path(@project.namespace, @project, @build) + context "Build from project" do + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + @build.run! + @build.trace = 'BUILD TRACE' + visit namespace_project_build_path(@project.namespace, @project, @build) + page.within('.build-controls') { click_link 'Raw' } + end + + it 'sends the right headers' do + expect(page.status_code).to eq(200) + expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') + expect(page.response_headers['X-Sendfile']).to eq(@build.path_to_trace) + end + end + + context "Build from other project" do + before do + Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + @build2.run! + @build2.trace = 'BUILD TRACE' + visit raw_namespace_project_build_path(@project.namespace, @project, @build2) + puts page.status_code + puts current_url + end + + it 'sends the right headers' do + expect(page.status_code).to eq(404) + end + end + end + + describe "GET /:project/builds/:id/trace.json" do + context "Build from project" do + before do + visit trace_namespace_project_build_path(@project.namespace, @project, @build, format: :json) + end + + it { expect(page.status_code).to eq(200) } + end + + context "Build from other project" do + before do + visit trace_namespace_project_build_path(@project.namespace, @project, @build2, format: :json) + end + + it { expect(page.status_code).to eq(404) } + end + end + + describe "GET /:project/builds/:id/status" do + context "Build from project" do + before do + visit status_namespace_project_build_path(@project.namespace, @project, @build) + end + + it { expect(page.status_code).to eq(200) } end - it 'sends the right headers' do - page.within('.build-controls') { click_link 'Raw' } + context "Build from other project" do + before do + visit status_namespace_project_build_path(@project.namespace, @project, @build2) + end - expect(page.response_headers['Content-Type']).to eq('text/plain; charset=utf-8') - expect(page.response_headers['X-Sendfile']).to eq(@build.path_to_trace) + it { expect(page.status_code).to eq(404) } end end end diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 41af789aae2..07a854ea014 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -28,7 +28,6 @@ describe 'Awards Emoji', feature: true do end context 'click the thumbsup emoji' do - it 'should increment the thumbsup emoji', js: true do find('[data-emoji="thumbsup"]').click sleep 2 @@ -41,7 +40,6 @@ describe 'Awards Emoji', feature: true do end context 'click the thumbsdown emoji' do - it 'should increment the thumbsdown emoji', js: true do find('[data-emoji="thumbsdown"]').click sleep 2 diff --git a/spec/features/issues/award_spec.rb b/spec/features/issues/award_spec.rb new file mode 100644 index 00000000000..63efecf8780 --- /dev/null +++ b/spec/features/issues/award_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +feature 'Issue awards', js: true, feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:issue) { create(:issue, project: project) } + + describe 'logged in' do + before do + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'should add award to issue' do + first('.js-emoji-btn').click + expect(page).to have_selector('.js-emoji-btn.active') + expect(first('.js-emoji-btn')).to have_content '1' + + visit namespace_project_issue_path(project.namespace, project, issue) + expect(first('.js-emoji-btn')).to have_content '1' + end + + it 'should remove award from issue' do + first('.js-emoji-btn').click + find('.js-emoji-btn.active').click + expect(first('.js-emoji-btn')).to have_content '0' + + visit namespace_project_issue_path(project.namespace, project, issue) + expect(first('.js-emoji-btn')).to have_content '0' + end + + it 'should only have one menu on the page' do + first('.js-add-award').click + expect(page).to have_selector('.emoji-menu') + + expect(page).to have_selector('.emoji-menu', count: 1) + end + end + + describe 'logged out' do + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'should not see award menu button' do + expect(page).not_to have_selector('.js-award-holder') + end + end +end diff --git a/spec/features/issues/bulk_assigment_labels_spec.rb b/spec/features/issues/bulk_assigment_labels_spec.rb new file mode 100644 index 00000000000..c58b87281a3 --- /dev/null +++ b/spec/features/issues/bulk_assigment_labels_spec.rb @@ -0,0 +1,196 @@ +require 'rails_helper' + +feature 'Issues > Labels bulk assignment', feature: true do + include WaitForAjax + + let(:user) { create(:user) } + let!(:project) { create(:project) } + let!(:issue1) { create(:issue, project: project, title: "Issue 1") } + let!(:issue2) { create(:issue, project: project, title: "Issue 2") } + let!(:bug) { create(:label, project: project, title: 'bug') } + let!(:feature) { create(:label, project: project, title: 'feature') } + + context 'as a allowed user', js: true do + before do + project.team << [user, :master] + + login_as user + end + + context 'can bulk assign' do + before do + visit namespace_project_issues_path(project.namespace, project) + end + + context 'a label' do + context 'to all issues' do + before do + check 'check_all_issues' + open_labels_dropdown ['bug'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).to have_content 'bug' + expect(find("#issue_#{issue2.id}")).to have_content 'bug' + end + end + + context 'to a issue' do + before do + check "selected_issue_#{issue1.id}" + open_labels_dropdown ['bug'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).to have_content 'bug' + expect(find("#issue_#{issue2.id}")).not_to have_content 'bug' + end + end + end + + context 'multiple labels' do + context 'to all issues' do + before do + check 'check_all_issues' + open_labels_dropdown ['bug', 'feature'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).to have_content 'bug' + expect(find("#issue_#{issue1.id}")).to have_content 'feature' + expect(find("#issue_#{issue2.id}")).to have_content 'bug' + expect(find("#issue_#{issue2.id}")).to have_content 'feature' + end + end + + context 'to a issue' do + before do + check "selected_issue_#{issue1.id}" + open_labels_dropdown ['bug', 'feature'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).to have_content 'bug' + expect(find("#issue_#{issue1.id}")).to have_content 'feature' + expect(find("#issue_#{issue2.id}")).not_to have_content 'bug' + expect(find("#issue_#{issue2.id}")).not_to have_content 'feature' + end + end + end + end + + context 'can bulk un-assign' do + context 'all labels to all issues' do + before do + issue1.labels << bug + issue1.labels << feature + issue2.labels << bug + issue2.labels << feature + + visit namespace_project_issues_path(project.namespace, project) + + check 'check_all_issues' + unmark_labels_in_dropdown ['bug', 'feature'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).not_to have_content 'bug' + expect(find("#issue_#{issue1.id}")).not_to have_content 'feature' + expect(find("#issue_#{issue2.id}")).not_to have_content 'bug' + expect(find("#issue_#{issue2.id}")).not_to have_content 'feature' + end + end + + context 'a label to a issue' do + before do + issue1.labels << bug + issue2.labels << feature + + visit namespace_project_issues_path(project.namespace, project) + + check_issue issue1 + unmark_labels_in_dropdown ['bug'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).not_to have_content 'bug' + expect(find("#issue_#{issue2.id}")).to have_content 'feature' + end + end + + context 'a label and keep the others label' do + before do + issue1.labels << bug + issue1.labels << feature + issue2.labels << bug + issue2.labels << feature + + visit namespace_project_issues_path(project.namespace, project) + + check_issue issue1 + check_issue issue2 + unmark_labels_in_dropdown ['bug'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).not_to have_content 'bug' + expect(find("#issue_#{issue1.id}")).to have_content 'feature' + expect(find("#issue_#{issue2.id}")).not_to have_content 'bug' + expect(find("#issue_#{issue2.id}")).to have_content 'feature' + end + end + end + end + + context 'as a guest' do + before do + login_as user + + visit namespace_project_issues_path(project.namespace, project) + end + + context 'cannot bulk assign labels' do + it do + expect(page).not_to have_css '.check_all_issues' + expect(page).not_to have_css '.issue-check' + end + end + end + + def open_labels_dropdown(items = [], unmark = false) + page.within('.issues_bulk_update') do + click_button 'Label' + wait_for_ajax + items.map do |item| + click_link item + end + if unmark + items.map do |item| + click_link item + end + end + end + end + + def unmark_labels_in_dropdown(items = []) + open_labels_dropdown(items, true) + end + + def check_issue(issue) + page.within('.issues-list') do + check "selected_issue_#{issue.id}" + end + end + + def update_issues + click_button 'Update issues' + wait_for_ajax + end +end diff --git a/spec/features/issues/update_issues_spec.rb b/spec/features/issues/update_issues_spec.rb index 466a6f7dfa7..ddbd69b2891 100644 --- a/spec/features/issues/update_issues_spec.rb +++ b/spec/features/issues/update_issues_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' feature 'Multiple issue updating from issues#index', feature: true do + include WaitForAjax + let!(:project) { create(:project) } let!(:issue) { create(:issue, project: project) } let!(:user) { create(:user)} @@ -24,9 +26,7 @@ feature 'Multiple issue updating from issues#index', feature: true do it 'should be set to open' do create_closed - visit namespace_project_issues_path(project.namespace, project) - - find('.issues-state-filters a', text: 'Closed').click + visit namespace_project_issues_path(project.namespace, project, state: 'closed') find('#check_all_issues').click find('.js-issue-status').click @@ -42,7 +42,7 @@ feature 'Multiple issue updating from issues#index', feature: true do visit namespace_project_issues_path(project.namespace, project) find('#check_all_issues').click - find('.js-update-assignee').click + click_update_assignee_button find('.dropdown-menu-user-link', text: user.username).click click_update_issues_button @@ -57,14 +57,11 @@ feature 'Multiple issue updating from issues#index', feature: true do visit namespace_project_issues_path(project.namespace, project) find('#check_all_issues').click - find('.js-update-assignee').click + click_update_assignee_button click_link 'Unassigned' click_update_issues_button - - within first('.issue .controls') do - expect(page).to have_no_selector('.author_link') - end + expect(find('.issue:first-child .controls')).not_to have_css('.author_link') end end @@ -95,7 +92,7 @@ feature 'Multiple issue updating from issues#index', feature: true do find('.dropdown-menu-milestone a', text: "No Milestone").click click_update_issues_button - expect(first('.issue')).not_to have_content milestone.title + expect(find('.issue:first-child')).not_to have_content milestone.title end end @@ -111,7 +108,13 @@ feature 'Multiple issue updating from issues#index', feature: true do create(:issue, project: project, milestone: milestone) end + def click_update_assignee_button + find('.js-update-assignee').click + wait_for_ajax + end + def click_update_issues_button find('.update_selected_issues').click + wait_for_ajax end end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 9271964166a..460d7f82b36 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -125,7 +125,7 @@ describe 'Issues', feature: true do describe 'Issue info' do it 'excludes award_emoji from comment count' do issue = create(:issue, author: @user, assignee: @user, project: project, title: 'foobar') - create(:upvote_note, noteable: issue, project: project) + create(:award_emoji, awardable: issue) visit namespace_project_issues_path(project.namespace, project, assignee_id: @user.id) @@ -365,13 +365,9 @@ describe 'Issues', feature: true do page.within('.assignee') do expect(page).to have_content "#{@user.name}" - end - find('.block.assignee .edit-link').click - sleep 2 # wait for ajax stuff to complete - first('.dropdown-menu-user-link').click - sleep 2 - page.within('.assignee') do + click_link 'Edit' + click_link 'Unassigned' expect(page).to have_content 'No assignee' end diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index c1b178c3b6c..72b5ff231f7 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -33,11 +33,11 @@ feature 'Login', feature: true do before do login_with(user, remember: true) - expect(page).to have_content('Two-factor Authentication') + expect(page).to have_content('Two-Factor Authentication') end def enter_code(code) - fill_in 'Two-factor Authentication code', with: code + fill_in 'Two-Factor Authentication code', with: code click_button 'Verify code' end @@ -143,12 +143,12 @@ feature 'Login', feature: true do context 'within the grace period' do it 'redirects to two-factor configuration page' do - expect(current_path).to eq new_profile_two_factor_auth_path - expect(page).to have_content('You must enable Two-factor Authentication for your account before') + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content('You must enable Two-Factor Authentication for your account before') end - it 'disallows skipping two-factor configuration' do - expect(current_path).to eq new_profile_two_factor_auth_path + it 'allows skipping two-factor configuration', js: true do + expect(current_path).to eq profile_two_factor_auth_path click_link 'Configure it later' expect(current_path).to eq root_path @@ -159,26 +159,26 @@ feature 'Login', feature: true do let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) } it 'redirects to two-factor configuration page' do - expect(current_path).to eq new_profile_two_factor_auth_path - expect(page).to have_content('You must enable Two-factor Authentication for your account.') + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content('You must enable Two-Factor Authentication for your account.') end - it 'disallows skipping two-factor configuration' do - expect(current_path).to eq new_profile_two_factor_auth_path + it 'disallows skipping two-factor configuration', js: true do + expect(current_path).to eq profile_two_factor_auth_path expect(page).not_to have_link('Configure it later') end end end - context 'without grace pariod defined' do + context 'without grace period defined' do before(:each) do stub_application_setting(two_factor_grace_period: 0) login_with(user) end it 'redirects to two-factor configuration page' do - expect(current_path).to eq new_profile_two_factor_auth_path - expect(page).to have_content('You must enable Two-factor Authentication for your account.') + expect(current_path).to eq profile_two_factor_auth_path + expect(page).to have_content('You must enable Two-Factor Authentication for your account.') end end end diff --git a/spec/features/merge_requests/award_spec.rb b/spec/features/merge_requests/award_spec.rb new file mode 100644 index 00000000000..007f67d6080 --- /dev/null +++ b/spec/features/merge_requests/award_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +feature 'Merge request awards', js: true, feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:merge_request) { create(:merge_request, source_project: project) } + + describe 'logged in' do + before do + login_as(user) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'should add award to merge request' do + first('.js-emoji-btn').click + expect(page).to have_selector('.js-emoji-btn.active') + expect(first('.js-emoji-btn')).to have_content '1' + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + expect(first('.js-emoji-btn')).to have_content '1' + end + + it 'should remove award from merge request' do + first('.js-emoji-btn').click + find('.js-emoji-btn.active').click + expect(first('.js-emoji-btn')).to have_content '0' + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + expect(first('.js-emoji-btn')).to have_content '0' + end + + it 'should only have one menu on the page' do + first('.js-add-award').click + expect(page).to have_selector('.emoji-menu') + + expect(page).to have_selector('.emoji-menu', count: 1) + end + end + + describe 'logged out' do + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'should not see award menu button' do + expect(page).not_to have_selector('.js-award-holder') + end + end +end diff --git a/spec/features/notes_on_merge_requests_spec.rb b/spec/features/notes_on_merge_requests_spec.rb index 2835cf44494..737efcef45d 100644 --- a/spec/features/notes_on_merge_requests_spec.rb +++ b/spec/features/notes_on_merge_requests_spec.rb @@ -4,20 +4,6 @@ describe 'Comments', feature: true do include RepoHelpers include WaitForAjax - describe 'On merge requests page', feature: true do - it 'excludes award_emoji from comment count' do - merge_request = create(:merge_request) - project = merge_request.source_project - create(:upvote_note, noteable: merge_request, project: project) - - login_as :admin - visit namespace_project_merge_requests_path(project.namespace, project) - - expect(merge_request.mr_and_commit_notes.count).to eq 1 - expect(page.all('.merge-request-no-comments').first.text).to eq "0" - end - end - describe 'On a merge request', js: true, feature: true do let!(:project) { create(:project) } let!(:merge_request) do @@ -147,17 +133,6 @@ describe 'Comments', feature: true do end end end - - describe 'comment info' do - it 'excludes award_emoji from comment count' do - create(:upvote_note, noteable: merge_request, project: project) - - visit namespace_project_merge_request_path(project.namespace, project, merge_request) - - expect(merge_request.mr_and_commit_notes.count).to eq 2 - expect(find('.notes-tab span.badge').text).to eq "1" - end - end end describe 'On a merge request diff', js: true, feature: true do diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb new file mode 100644 index 00000000000..366a90228b1 --- /dev/null +++ b/spec/features/u2f_spec.rb @@ -0,0 +1,239 @@ +require 'spec_helper' + +feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: true, js: true do + def register_u2f_device(u2f_device = nil) + u2f_device ||= FakeU2fDevice.new(page) + u2f_device.respond_to_u2f_registration + click_on 'Setup New U2F Device' + expect(page).to have_content('Your device was successfully set up') + click_on 'Register U2F Device' + u2f_device + end + + describe "registration" do + let(:user) { create(:user) } + before { login_as(user) } + + describe 'when 2FA via OTP is disabled' do + it 'allows registering a new device' do + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + + register_u2f_device + + expect(page.body).to match('Your U2F device was registered') + end + + it 'allows registering more than one device' do + visit profile_account_path + + # First device + click_on 'Enable Two-Factor Authentication' + register_u2f_device + expect(page.body).to match('Your U2F device was registered') + + # Second device + click_on 'Manage Two-Factor Authentication' + register_u2f_device + expect(page.body).to match('Your U2F device was registered') + click_on 'Manage Two-Factor Authentication' + + expect(page.body).to match('You have 2 U2F devices registered') + end + end + + describe 'when 2FA via OTP is enabled' do + before { user.update_attributes(otp_required_for_login: true) } + + it 'allows registering a new device' do + visit profile_account_path + click_on 'Manage Two-Factor Authentication' + expect(page.body).to match("You've already enabled two-factor authentication using mobile") + + register_u2f_device + + expect(page.body).to match('Your U2F device was registered') + end + + it 'allows registering more than one device' do + visit profile_account_path + + # First device + click_on 'Manage Two-Factor Authentication' + register_u2f_device + expect(page.body).to match('Your U2F device was registered') + + # Second device + click_on 'Manage Two-Factor Authentication' + register_u2f_device + expect(page.body).to match('Your U2F device was registered') + + click_on 'Manage Two-Factor Authentication' + expect(page.body).to match('You have 2 U2F devices registered') + end + end + + it 'allows the same device to be registered for multiple users' do + # First user + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + u2f_device = register_u2f_device + expect(page.body).to match('Your U2F device was registered') + logout + + # Second user + login_as(:user) + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + register_u2f_device(u2f_device) + expect(page.body).to match('Your U2F device was registered') + + expect(U2fRegistration.count).to eq(2) + end + + context "when there are form errors" do + it "doesn't register the device if there are errors" do + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + + # Have the "u2f device" respond with bad data + page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") + click_on 'Setup New U2F Device' + expect(page).to have_content('Your device was successfully set up') + click_on 'Register U2F Device' + + expect(U2fRegistration.count).to eq(0) + expect(page.body).to match("The form contains the following error") + expect(page.body).to match("did not send a valid JSON response") + end + + it "allows retrying registration" do + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + + # Failed registration + page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") + click_on 'Setup New U2F Device' + expect(page).to have_content('Your device was successfully set up') + click_on 'Register U2F Device' + expect(page.body).to match("The form contains the following error") + + # Successful registration + register_u2f_device + + expect(page.body).to match('Your U2F device was registered') + expect(U2fRegistration.count).to eq(1) + end + end + end + + describe "authentication" do + let(:user) { create(:user) } + + before do + # Register and logout + login_as(user) + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + @u2f_device = register_u2f_device + logout + end + + describe "when 2FA via OTP is disabled" do + it "allows logging in with the U2F device" do + login_with(user) + + @u2f_device.respond_to_u2f_authentication + click_on "Login Via U2F Device" + expect(page.body).to match('We heard back from your U2F device') + click_on "Authenticate via U2F Device" + + expect(page.body).to match('Signed in successfully') + end + end + + describe "when 2FA via OTP is enabled" do + it "allows logging in with the U2F device" do + user.update_attributes(otp_required_for_login: true) + login_with(user) + + @u2f_device.respond_to_u2f_authentication + click_on "Login Via U2F Device" + expect(page.body).to match('We heard back from your U2F device') + click_on "Authenticate via U2F Device" + + expect(page.body).to match('Signed in successfully') + end + end + + describe "when a given U2F device has already been registered by another user" do + describe "but not the current user" do + it "does not allow logging in with that particular device" do + # Register current user with the different U2F device + current_user = login_as(:user) + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + register_u2f_device + logout + + # Try authenticating user with the old U2F device + login_as(current_user) + @u2f_device.respond_to_u2f_authentication + click_on "Login Via U2F Device" + expect(page.body).to match('We heard back from your U2F device') + click_on "Authenticate via U2F Device" + + expect(page.body).to match('Authentication via U2F device failed') + end + end + + describe "and also the current user" do + it "allows logging in with that particular device" do + # Register current user with the same U2F device + current_user = login_as(:user) + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + register_u2f_device(@u2f_device) + logout + + # Try authenticating user with the same U2F device + login_as(current_user) + @u2f_device.respond_to_u2f_authentication + click_on "Login Via U2F Device" + expect(page.body).to match('We heard back from your U2F device') + click_on "Authenticate via U2F Device" + + expect(page.body).to match('Signed in successfully') + end + end + end + + describe "when a given U2F device has not been registered" do + it "does not allow logging in with that particular device" do + unregistered_device = FakeU2fDevice.new(page) + login_as(user) + unregistered_device.respond_to_u2f_authentication + click_on "Login Via U2F Device" + expect(page.body).to match('We heard back from your U2F device') + click_on "Authenticate via U2F Device" + + expect(page.body).to match('Authentication via U2F device failed') + end + end + end + + describe "when two-factor authentication is disabled" do + let(:user) { create(:user) } + + before do + login_as(user) + visit profile_account_path + click_on 'Enable Two-Factor Authentication' + register_u2f_device + end + + it "deletes u2f registrations" do + expect { click_on "Disable" }.to change { U2fRegistration.count }.from(1).to(0) + end + end +end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index bffe2c18b6f..eae61a54dfc 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -163,18 +163,15 @@ describe IssuesHelper do it { is_expected.to eq("!1, !2, or !3") } end - describe "note_active_class" do - before do - @note = create :note - @note1 = create :note - end + describe '#award_active_class' do + let!(:upvote) { create(:award_emoji) } it "returns empty string for unauthenticated user" do - expect(note_active_class(Note.all, nil)).to eq("") + expect(award_active_class(AwardEmoji.all, nil)).to eq("") end it "returns active string for author" do - expect(note_active_class(Note.all, @note.author)).to eq("active") + expect(award_active_class(AwardEmoji.all, upvote.user)).to eq("active") end end diff --git a/spec/javascripts/awards_handler_spec.js.coffee b/spec/javascripts/awards_handler_spec.js.coffee new file mode 100644 index 00000000000..0bd6d696387 --- /dev/null +++ b/spec/javascripts/awards_handler_spec.js.coffee @@ -0,0 +1,202 @@ +#= require awards_handler +#= require jquery +#= require jquery.cookie +#= require ./fixtures/emoji_menu + +awardsHandler = null +window.gl or= {} +gl.emojiAliases = -> return { '+1': 'thumbsup', '-1': 'thumbsdown' } +gl.awardMenuUrl = '/emojis' + + +lazyAssert = (done, assertFn) -> + + setTimeout -> # Maybe jasmine.clock here? + assertFn() + done() + , 333 + + +describe 'AwardsHandler', -> + + fixture.preload 'awards_handler.html' + + beforeEach -> + fixture.load 'awards_handler.html' + awardsHandler = new AwardsHandler + spyOn(awardsHandler, 'postEmoji').and.callFake (url, emoji, cb) => cb() + spyOn(jQuery, 'get').and.callFake (req, cb) -> + expect(req).toBe '/emojis' + cb window.emojiMenu + + + describe '::showEmojiMenu', -> + + it 'should show emoji menu when Add emoji button clicked', (done) -> + + $('.js-add-award').eq(0).click() + + lazyAssert done, -> + $emojiMenu = $ '.emoji-menu' + expect($emojiMenu.length).toBe 1 + expect($emojiMenu.hasClass('is-visible')).toBe yes + expect($emojiMenu.find('#emoji_search').length).toBe 1 + expect($('.js-awards-block.current').length).toBe 1 + + + it 'should also show emoji menu for the smiley icon in notes', (done) -> + + $('.note-action-button').click() + + lazyAssert done, -> + $emojiMenu = $ '.emoji-menu' + expect($emojiMenu.length).toBe 1 + + + it 'should remove emoji menu when body is clicked', (done) -> + + $('.js-add-award').eq(0).click() + + lazyAssert done, -> + $emojiMenu = $('.emoji-menu') + $('body').click() + expect($emojiMenu.length).toBe 1 + expect($emojiMenu.hasClass('is-visible')).toBe no + expect($('.js-awards-block.current').length).toBe 0 + + + describe '::addAwardToEmojiBar', -> + + it 'should add emoji to votes block', -> + + $votesBlock = $('.js-awards-block').eq 0 + awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no + + $emojiButton = $votesBlock.find '[data-emoji=heart]' + + expect($emojiButton.length).toBe 1 + expect($emojiButton.next('.js-counter').text()).toBe '1' + expect($votesBlock.hasClass('hidden')).toBe no + + + it 'should remove the emoji when we click again', -> + + $votesBlock = $('.js-awards-block').eq 0 + awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no + awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no + $emojiButton = $votesBlock.find '[data-emoji=heart]' + + expect($emojiButton.length).toBe 0 + + + it 'should decrement the emoji counter', -> + + $votesBlock = $('.js-awards-block').eq 0 + awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no + + $emojiButton = $votesBlock.find '[data-emoji=heart]' + $emojiButton.next('.js-counter').text 5 + + awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no + + expect($emojiButton.length).toBe 1 + expect($emojiButton.next('.js-counter').text()).toBe '4' + + + describe '::getAwardUrl', -> + + it 'should return the url for request', -> + + expect(awardsHandler.getAwardUrl()).toBe '/gitlab-org/gitlab-test/issues/8/toggle_award_emoji' + + + describe '::addAward and ::checkMutuality', -> + + it 'should handle :+1: and :-1: mutuality', -> + + awardUrl = awardsHandler.getAwardUrl() + $votesBlock = $('.js-awards-block').eq 0 + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent() + $thumbsDownEmoji = $votesBlock.find('[data-emoji=thumbsdown]').parent() + + awardsHandler.addAward $votesBlock, awardUrl, 'thumbsup', no + + expect($thumbsUpEmoji.hasClass('active')).toBe yes + expect($thumbsDownEmoji.hasClass('active')).toBe no + + $thumbsUpEmoji.tooltip() + $thumbsDownEmoji.tooltip() + + awardsHandler.addAward $votesBlock, awardUrl, 'thumbsdown', yes + + expect($thumbsUpEmoji.hasClass('active')).toBe no + expect($thumbsDownEmoji.hasClass('active')).toBe yes + + + describe '::removeEmoji', -> + + it 'should remove emoji', -> + + awardUrl = awardsHandler.getAwardUrl() + $votesBlock = $('.js-awards-block').eq 0 + + awardsHandler.addAward $votesBlock, awardUrl, 'fire', no + expect($votesBlock.find('[data-emoji=fire]').length).toBe 1 + + awardsHandler.removeEmoji $votesBlock.find('[data-emoji=fire]').closest('button') + expect($votesBlock.find('[data-emoji=fire]').length).toBe 0 + + + describe 'search', -> + + it 'should filter the emoji', -> + + $('.js-add-award').eq(0).click() + + expect($('[data-emoji=angel]').is(':visible')).toBe yes + expect($('[data-emoji=anger]').is(':visible')).toBe yes + + $('#emoji_search').val('ali').trigger 'keyup' + + expect($('[data-emoji=angel]').is(':visible')).toBe no + expect($('[data-emoji=anger]').is(':visible')).toBe no + expect($('[data-emoji=alien]').is(':visible')).toBe yes + expect($('h5.emoji-search').is(':visible')).toBe yes + + + describe 'emoji menu', -> + + selector = '[data-emoji=sunglasses]' + + openEmojiMenuAndAddEmoji = -> + + $('.js-add-award').eq(0).click() + + $menu = $ '.emoji-menu' + $block = $ '.js-awards-block' + $emoji = $menu.find ".emoji-menu-list-item #{selector}" + + expect($emoji.length).toBe 1 + expect($block.find(selector).length).toBe 0 + + $emoji.click() + + expect($menu.hasClass('.is-visible')).toBe no + expect($block.find(selector).length).toBe 1 + + + it 'should add selected emoji to awards block', -> + + openEmojiMenuAndAddEmoji() + + + it 'should remove already selected emoji', -> + + openEmojiMenuAndAddEmoji() + $('.js-add-award').eq(0).click() + + $block = $ '.js-awards-block' + $emoji = $('.emoji-menu').find ".emoji-menu-list-item #{selector}" + + $emoji.click() + expect($block.find(selector).length).toBe 0 diff --git a/spec/javascripts/behaviors/quick_submit_spec.js.coffee b/spec/javascripts/behaviors/quick_submit_spec.js.coffee index 09708c12ed4..d3b003a328a 100644 --- a/spec/javascripts/behaviors/quick_submit_spec.js.coffee +++ b/spec/javascripts/behaviors/quick_submit_spec.js.coffee @@ -14,17 +14,17 @@ describe 'Quick Submit behavior', -> } it 'does not respond to other keyCodes', -> - $('input').trigger(keydownEvent(keyCode: 32)) + $('input.quick-submit-input').trigger(keydownEvent(keyCode: 32)) expect(@spies.submit).not.toHaveBeenTriggered() it 'does not respond to Enter alone', -> - $('input').trigger(keydownEvent(ctrlKey: false, metaKey: false)) + $('input.quick-submit-input').trigger(keydownEvent(ctrlKey: false, metaKey: false)) expect(@spies.submit).not.toHaveBeenTriggered() it 'does not respond to repeated events', -> - $('input').trigger(keydownEvent(repeat: true)) + $('input.quick-submit-input').trigger(keydownEvent(repeat: true)) expect(@spies.submit).not.toHaveBeenTriggered() @@ -38,26 +38,26 @@ describe 'Quick Submit behavior', -> # only run the tests that apply to the current platform if navigator.userAgent.match(/Macintosh/) it 'responds to Meta+Enter', -> - $('input').trigger(keydownEvent()) + $('input.quick-submit-input').trigger(keydownEvent()) expect(@spies.submit).toHaveBeenTriggered() it 'excludes other modifier keys', -> - $('input').trigger(keydownEvent(altKey: true)) - $('input').trigger(keydownEvent(ctrlKey: true)) - $('input').trigger(keydownEvent(shiftKey: true)) + $('input.quick-submit-input').trigger(keydownEvent(altKey: true)) + $('input.quick-submit-input').trigger(keydownEvent(ctrlKey: true)) + $('input.quick-submit-input').trigger(keydownEvent(shiftKey: true)) expect(@spies.submit).not.toHaveBeenTriggered() else it 'responds to Ctrl+Enter', -> - $('input').trigger(keydownEvent()) + $('input.quick-submit-input').trigger(keydownEvent()) expect(@spies.submit).toHaveBeenTriggered() it 'excludes other modifier keys', -> - $('input').trigger(keydownEvent(altKey: true)) - $('input').trigger(keydownEvent(metaKey: true)) - $('input').trigger(keydownEvent(shiftKey: true)) + $('input.quick-submit-input').trigger(keydownEvent(altKey: true)) + $('input.quick-submit-input').trigger(keydownEvent(metaKey: true)) + $('input.quick-submit-input').trigger(keydownEvent(shiftKey: true)) expect(@spies.submit).not.toHaveBeenTriggered() diff --git a/spec/javascripts/fixtures/awards_handler.html.haml b/spec/javascripts/fixtures/awards_handler.html.haml new file mode 100644 index 00000000000..d55936ee4f9 --- /dev/null +++ b/spec/javascripts/fixtures/awards_handler.html.haml @@ -0,0 +1,52 @@ +.issue-details.issuable-details + .detail-page-description.content-block + %h2.title Quibusdam sint officiis earum molestiae ipsa autem voluptatem nisi rem. + .description.js-task-list-container.is-task-list-enabled + .wiki + %p Qui exercitationem magnam optio quae fuga earum odio. + %textarea.hidden.js-task-list-field Qui exercitationem magnam optio quae fuga earum odio. + %small.edited-text + .content-block.content-block-small + .awards.js-awards-block{"data-award-url" => "/gitlab-org/gitlab-test/issues/8/toggle_award_emoji"} + %button.award-control.btn.js-emoji-btn{"data-placement" => "bottom", "data-title" => "", :type => "button"} + .icon.emoji-icon.emoji-1F44D{"data-aliases" => "", "data-emoji" => "thumbsup", "data-unicode-name" => "1F44D", :title => "thumbsup"} + %span.award-control-text.js-counter 0 + %button.award-control.btn.js-emoji-btn{"data-placement" => "bottom", "data-title" => "", :type => "button"} + .icon.emoji-icon.emoji-1F44E{"data-aliases" => "", "data-emoji" => "thumbsdown", "data-unicode-name" => "1F44E", :title => "thumbsdown"} + %span.award-control-text.js-counter 0 + .award-menu-holder.js-award-holder + %button.btn.award-control.js-add-award{:type => "button"} + %i.fa.fa-smile-o.award-control-icon.award-control-icon-normal + %i.fa.fa-spinner.fa-spin.award-control-icon.award-control-icon-loading + %span.award-control-text Add + %section.issuable-discussion + #notes + %ul#notes-list.notes.main-notes-list.timeline + %li#note_348.note.note-row-348.timeline-entry{"data-author-id" => "18", "data-editable" => ""} + .timeline-entry-inner + .timeline-icon + %a{:href => "/u/agustin"} + %img.avatar.s40{:alt => "", :src => "#"}/ + .timeline-content + .note-header + %a.author_link{:href => "/u/agustin"} + %span.author Brenna Stokes + .inline.note-headline-light + @agustin commented + %a{:href => "#note_348"} + %time 11 days ago + .note-actions + %span.note-role Reporter + %a.note-action-button.note-emoji-button.js-add-award.js-note-emoji{"data-position" => "right", :href => "#", :title => "Award Emoji"} + %i.fa.fa-spinner.fa-spin + %i.fa.fa-smile-o + .js-task-list-container.note-body.is-task-list-enabled + .note-text + %p Suscipit sunt quia quisquam sed eveniet ipsam. + .note-awards + .awards.hidden.js-awards-block{"data-award-url" => "/gitlab-org/gitlab-test/notes/348/toggle_award_emoji"} + .award-menu-holder.js-award-holder + %button.btn.award-control.js-add-award{:type => "button"} + %i.fa.fa-smile-o.award-control-icon.award-control-icon-normal + %i.fa.fa-spinner.fa-spin.award-control-icon.award-control-icon-loading + %span.award-control-text Add diff --git a/spec/javascripts/fixtures/behaviors/quick_submit.html.haml b/spec/javascripts/fixtures/behaviors/quick_submit.html.haml index e3788bee813..dc2ceed42f4 100644 --- a/spec/javascripts/fixtures/behaviors/quick_submit.html.haml +++ b/spec/javascripts/fixtures/behaviors/quick_submit.html.haml @@ -1,5 +1,5 @@ %form.js-quick-submit{ action: '/foo' } - %input{ type: 'text' } + %input{ type: 'text', class: 'quick-submit-input'} %textarea %input{ type: 'submit'} Submit diff --git a/spec/javascripts/fixtures/emoji_menu.coffee b/spec/javascripts/fixtures/emoji_menu.coffee new file mode 100644 index 00000000000..e529dd5f1cd --- /dev/null +++ b/spec/javascripts/fixtures/emoji_menu.coffee @@ -0,0 +1,957 @@ +window.emojiMenu = """ + <div class='emoji-menu'> + <div class='emoji-menu-content'> + <input type="text" name="emoji_search" id="emoji_search" value="" class="emoji-search search-input form-control" /> + <h5 class='emoji-menu-title'> + Emoticons + </h5> + <ul class='clearfix emoji-menu-list'> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F47D" title="alien" data-aliases="" data-emoji="alien" data-unicode-name="1F47D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F47C" title="angel" data-aliases="" data-emoji="angel" data-unicode-name="1F47C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4A2" title="anger" data-aliases="" data-emoji="anger" data-unicode-name="1F4A2"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F620" title="angry" data-aliases="" data-emoji="angry" data-unicode-name="1F620"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F627" title="anguished" data-aliases="" data-emoji="anguished" data-unicode-name="1F627"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F632" title="astonished" data-aliases="" data-emoji="astonished" data-unicode-name="1F632"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F45F" title="athletic_shoe" data-aliases="" data-emoji="athletic_shoe" data-unicode-name="1F45F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F476" title="baby" data-aliases="" data-emoji="baby" data-unicode-name="1F476"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F459" title="bikini" data-aliases="" data-emoji="bikini" data-unicode-name="1F459"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F499" title="blue_heart" data-aliases="" data-emoji="blue_heart" data-unicode-name="1F499"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F60A" title="blush" data-aliases="" data-emoji="blush" data-unicode-name="1F60A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4A5" title="boom" data-aliases="" data-emoji="boom" data-unicode-name="1F4A5"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F462" title="boot" data-aliases="" data-emoji="boot" data-unicode-name="1F462"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F647" title="bow" data-aliases="" data-emoji="bow" data-unicode-name="1F647"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F466" title="boy" data-aliases="" data-emoji="boy" data-unicode-name="1F466"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F470" title="bride_with_veil" data-aliases="" data-emoji="bride_with_veil" data-unicode-name="1F470"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4BC" title="briefcase" data-aliases="" data-emoji="briefcase" data-unicode-name="1F4BC"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F494" title="broken_heart" data-aliases="" data-emoji="broken_heart" data-unicode-name="1F494"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F464" title="bust_in_silhouette" data-aliases="" data-emoji="bust_in_silhouette" data-unicode-name="1F464"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F465" title="busts_in_silhouette" data-aliases="" data-emoji="busts_in_silhouette" data-unicode-name="1F465"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F44F" title="clap" data-aliases="" data-emoji="clap" data-unicode-name="1F44F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F302" title="closed_umbrella" data-aliases="" data-emoji="closed_umbrella" data-unicode-name="1F302"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F630" title="cold_sweat" data-aliases="" data-emoji="cold_sweat" data-unicode-name="1F630"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F616" title="confounded" data-aliases="" data-emoji="confounded" data-unicode-name="1F616"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F615" title="confused" data-aliases="" data-emoji="confused" data-unicode-name="1F615"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F477" title="construction_worker" data-aliases="" data-emoji="construction_worker" data-unicode-name="1F477"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F46E" title="cop" data-aliases="" data-emoji="cop" data-unicode-name="1F46E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F46B" title="couple" data-aliases="" data-emoji="couple" data-unicode-name="1F46B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F491" title="couple_with_heart" data-aliases="" data-emoji="couple_with_heart" data-unicode-name="1F491"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F48F" title="couplekiss" data-aliases="" data-emoji="couplekiss" data-unicode-name="1F48F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F451" title="crown" data-aliases="" data-emoji="crown" data-unicode-name="1F451"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F622" title="cry" data-aliases="" data-emoji="cry" data-unicode-name="1F622"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F63F" title="crying_cat_face" data-aliases="" data-emoji="crying_cat_face" data-unicode-name="1F63F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F498" title="cupid" data-aliases="" data-emoji="cupid" data-unicode-name="1F498"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F483" title="dancer" data-aliases="" data-emoji="dancer" data-unicode-name="1F483"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F46F" title="dancers" data-aliases="" data-emoji="dancers" data-unicode-name="1F46F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4A8" title="dash" data-aliases="" data-emoji="dash" data-unicode-name="1F4A8"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F61E" title="disappointed" data-aliases="" data-emoji="disappointed" data-unicode-name="1F61E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F625" title="disappointed_relieved" data-aliases="" data-emoji="disappointed_relieved" data-unicode-name="1F625"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4AB" title="dizzy" data-aliases="" data-emoji="dizzy" data-unicode-name="1F4AB"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F635" title="dizzy_face" data-aliases="" data-emoji="dizzy_face" data-unicode-name="1F635"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F457" title="dress" data-aliases="" data-emoji="dress" data-unicode-name="1F457"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4A7" title="droplet" data-aliases="" data-emoji="droplet" data-unicode-name="1F4A7"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F442" title="ear" data-aliases="" data-emoji="ear" data-unicode-name="1F442"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F611" title="expressionless" data-aliases="" data-emoji="expressionless" data-unicode-name="1F611"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F453" title="eyeglasses" data-aliases="" data-emoji="eyeglasses" data-unicode-name="1F453"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F440" title="eyes" data-aliases="" data-emoji="eyes" data-unicode-name="1F440"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F46A" title="family" data-aliases="" data-emoji="family" data-unicode-name="1F46A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F628" title="fearful" data-aliases="" data-emoji="fearful" data-unicode-name="1F628"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F525" title="fire" data-aliases=":flame:" data-emoji="fire" data-unicode-name="1F525"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-270A" title="fist" data-aliases="" data-emoji="fist" data-unicode-name="270A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F633" title="flushed" data-aliases="" data-emoji="flushed" data-unicode-name="1F633"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F463" title="footprints" data-aliases="" data-emoji="footprints" data-unicode-name="1F463"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F626" title="frowning" data-aliases=":anguished:" data-emoji="frowning" data-unicode-name="1F626"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F48E" title="gem" data-aliases="" data-emoji="gem" data-unicode-name="1F48E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F467" title="girl" data-aliases="" data-emoji="girl" data-unicode-name="1F467"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F49A" title="green_heart" data-aliases="" data-emoji="green_heart" data-unicode-name="1F49A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F62C" title="grimacing" data-aliases="" data-emoji="grimacing" data-unicode-name="1F62C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F601" title="grin" data-aliases="" data-emoji="grin" data-unicode-name="1F601"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F600" title="grinning" data-aliases="" data-emoji="grinning" data-unicode-name="1F600"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F482" title="guardsman" data-aliases="" data-emoji="guardsman" data-unicode-name="1F482"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F487" title="haircut" data-aliases="" data-emoji="haircut" data-unicode-name="1F487"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F45C" title="handbag" data-aliases="" data-emoji="handbag" data-unicode-name="1F45C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F649" title="hear_no_evil" data-aliases="" data-emoji="hear_no_evil" data-unicode-name="1F649"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-2764" title="heart" data-aliases="" data-emoji="heart" data-unicode-name="2764"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F60D" title="heart_eyes" data-aliases="" data-emoji="heart_eyes" data-unicode-name="1F60D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F63B" title="heart_eyes_cat" data-aliases="" data-emoji="heart_eyes_cat" data-unicode-name="1F63B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F493" title="heartbeat" data-aliases="" data-emoji="heartbeat" data-unicode-name="1F493"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F497" title="heartpulse" data-aliases="" data-emoji="heartpulse" data-unicode-name="1F497"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F460" title="high_heel" data-aliases="" data-emoji="high_heel" data-unicode-name="1F460"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F62F" title="hushed" data-aliases="" data-emoji="hushed" data-unicode-name="1F62F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F47F" title="imp" data-aliases="" data-emoji="imp" data-unicode-name="1F47F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F481" title="information_desk_person" data-aliases="" data-emoji="information_desk_person" data-unicode-name="1F481"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F607" title="innocent" data-aliases="" data-emoji="innocent" data-unicode-name="1F607"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F47A" title="japanese_goblin" data-aliases="" data-emoji="japanese_goblin" data-unicode-name="1F47A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F479" title="japanese_ogre" data-aliases="" data-emoji="japanese_ogre" data-unicode-name="1F479"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F456" title="jeans" data-aliases="" data-emoji="jeans" data-unicode-name="1F456"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F602" title="joy" data-aliases="" data-emoji="joy" data-unicode-name="1F602"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F639" title="joy_cat" data-aliases="" data-emoji="joy_cat" data-unicode-name="1F639"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F458" title="kimono" data-aliases="" data-emoji="kimono" data-unicode-name="1F458"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F48B" title="kiss" data-aliases="" data-emoji="kiss" data-unicode-name="1F48B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F617" title="kissing" data-aliases="" data-emoji="kissing" data-unicode-name="1F617"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F63D" title="kissing_cat" data-aliases="" data-emoji="kissing_cat" data-unicode-name="1F63D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F61A" title="kissing_closed_eyes" data-aliases="" data-emoji="kissing_closed_eyes" data-unicode-name="1F61A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F618" title="kissing_heart" data-aliases="" data-emoji="kissing_heart" data-unicode-name="1F618"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F619" title="kissing_smiling_eyes" data-aliases="" data-emoji="kissing_smiling_eyes" data-unicode-name="1F619"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F606" title="laughing" data-aliases=":satisfied:" data-emoji="laughing" data-unicode-name="1F606"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F444" title="lips" data-aliases="" data-emoji="lips" data-unicode-name="1F444"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F484" title="lipstick" data-aliases="" data-emoji="lipstick" data-unicode-name="1F484"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F48C" title="love_letter" data-aliases="" data-emoji="love_letter" data-unicode-name="1F48C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F468" title="man" data-aliases="" data-emoji="man" data-unicode-name="1F468"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F472" title="man_with_gua_pi_mao" data-aliases="" data-emoji="man_with_gua_pi_mao" data-unicode-name="1F472"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F473" title="man_with_turban" data-aliases="" data-emoji="man_with_turban" data-unicode-name="1F473"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F45E" title="mans_shoe" data-aliases="" data-emoji="mans_shoe" data-unicode-name="1F45E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F637" title="mask" data-aliases="" data-emoji="mask" data-unicode-name="1F637"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F486" title="massage" data-aliases="" data-emoji="massage" data-unicode-name="1F486"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4AA" title="muscle" data-aliases="" data-emoji="muscle" data-unicode-name="1F4AA"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F485" title="nail_care" data-aliases="" data-emoji="nail_care" data-unicode-name="1F485"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F454" title="necktie" data-aliases="" data-emoji="necktie" data-unicode-name="1F454"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F610" title="neutral_face" data-aliases="" data-emoji="neutral_face" data-unicode-name="1F610"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F645" title="no_good" data-aliases="" data-emoji="no_good" data-unicode-name="1F645"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F636" title="no_mouth" data-aliases="" data-emoji="no_mouth" data-unicode-name="1F636"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F443" title="nose" data-aliases="" data-emoji="nose" data-unicode-name="1F443"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F44C" title="ok_hand" data-aliases="" data-emoji="ok_hand" data-unicode-name="1F44C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F646" title="ok_woman" data-aliases="" data-emoji="ok_woman" data-unicode-name="1F646"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F474" title="older_man" data-aliases="" data-emoji="older_man" data-unicode-name="1F474"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F475" title="older_woman" data-aliases=":grandma:" data-emoji="older_woman" data-unicode-name="1F475"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F450" title="open_hands" data-aliases="" data-emoji="open_hands" data-unicode-name="1F450"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F62E" title="open_mouth" data-aliases="" data-emoji="open_mouth" data-unicode-name="1F62E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F614" title="pensive" data-aliases="" data-emoji="pensive" data-unicode-name="1F614"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F623" title="persevere" data-aliases="" data-emoji="persevere" data-unicode-name="1F623"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F64D" title="person_frowning" data-aliases="" data-emoji="person_frowning" data-unicode-name="1F64D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F471" title="person_with_blond_hair" data-aliases="" data-emoji="person_with_blond_hair" data-unicode-name="1F471"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F64E" title="person_with_pouting_face" data-aliases="" data-emoji="person_with_pouting_face" data-unicode-name="1F64E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F447" title="point_down" data-aliases="" data-emoji="point_down" data-unicode-name="1F447"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F448" title="point_left" data-aliases="" data-emoji="point_left" data-unicode-name="1F448"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F449" title="point_right" data-aliases="" data-emoji="point_right" data-unicode-name="1F449"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-261D" title="point_up" data-aliases="" data-emoji="point_up" data-unicode-name="261D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F446" title="point_up_2" data-aliases="" data-emoji="point_up_2" data-unicode-name="1F446"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4A9" title="poop" data-aliases=":shit: :hankey: :poo:" data-emoji="poop" data-unicode-name="1F4A9"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F45D" title="pouch" data-aliases="" data-emoji="pouch" data-unicode-name="1F45D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F63E" title="pouting_cat" data-aliases="" data-emoji="pouting_cat" data-unicode-name="1F63E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F64F" title="pray" data-aliases="" data-emoji="pray" data-unicode-name="1F64F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F478" title="princess" data-aliases="" data-emoji="princess" data-unicode-name="1F478"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F44A" title="punch" data-aliases="" data-emoji="punch" data-unicode-name="1F44A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F49C" title="purple_heart" data-aliases="" data-emoji="purple_heart" data-unicode-name="1F49C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F45B" title="purse" data-aliases="" data-emoji="purse" data-unicode-name="1F45B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F621" title="rage" data-aliases="" data-emoji="rage" data-unicode-name="1F621"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-270B" title="raised_hand" data-aliases="" data-emoji="raised_hand" data-unicode-name="270B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F64C" title="raised_hands" data-aliases="" data-emoji="raised_hands" data-unicode-name="1F64C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F64B" title="raising_hand" data-aliases="" data-emoji="raising_hand" data-unicode-name="1F64B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-263A" title="relaxed" data-aliases="" data-emoji="relaxed" data-unicode-name="263A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F60C" title="relieved" data-aliases="" data-emoji="relieved" data-unicode-name="1F60C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F49E" title="revolving_hearts" data-aliases="" data-emoji="revolving_hearts" data-unicode-name="1F49E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F380" title="ribbon" data-aliases="" data-emoji="ribbon" data-unicode-name="1F380"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F48D" title="ring" data-aliases="" data-emoji="ring" data-unicode-name="1F48D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F3C3" title="runner" data-aliases="" data-emoji="runner" data-unicode-name="1F3C3"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F3BD" title="running_shirt_with_sash" data-aliases="" data-emoji="running_shirt_with_sash" data-unicode-name="1F3BD"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F461" title="sandal" data-aliases="" data-emoji="sandal" data-unicode-name="1F461"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F631" title="scream" data-aliases="" data-emoji="scream" data-unicode-name="1F631"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F640" title="scream_cat" data-aliases="" data-emoji="scream_cat" data-unicode-name="1F640"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F648" title="see_no_evil" data-aliases="" data-emoji="see_no_evil" data-unicode-name="1F648"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F455" title="shirt" data-aliases="" data-emoji="shirt" data-unicode-name="1F455"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F480" title="skull" data-aliases=":skeleton:" data-emoji="skull" data-unicode-name="1F480"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F634" title="sleeping" data-aliases="" data-emoji="sleeping" data-unicode-name="1F634"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F62A" title="sleepy" data-aliases="" data-emoji="sleepy" data-unicode-name="1F62A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F604" title="smile" data-aliases="" data-emoji="smile" data-unicode-name="1F604"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F638" title="smile_cat" data-aliases="" data-emoji="smile_cat" data-unicode-name="1F638"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F603" title="smiley" data-aliases="" data-emoji="smiley" data-unicode-name="1F603"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F63A" title="smiley_cat" data-aliases="" data-emoji="smiley_cat" data-unicode-name="1F63A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F608" title="smiling_imp" data-aliases="" data-emoji="smiling_imp" data-unicode-name="1F608"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F60F" title="smirk" data-aliases="" data-emoji="smirk" data-unicode-name="1F60F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F63C" title="smirk_cat" data-aliases="" data-emoji="smirk_cat" data-unicode-name="1F63C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F62D" title="sob" data-aliases="" data-emoji="sob" data-unicode-name="1F62D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-2728" title="sparkles" data-aliases="" data-emoji="sparkles" data-unicode-name="2728"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F496" title="sparkling_heart" data-aliases="" data-emoji="sparkling_heart" data-unicode-name="1F496"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F64A" title="speak_no_evil" data-aliases="" data-emoji="speak_no_evil" data-unicode-name="1F64A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4AC" title="speech_balloon" data-aliases="" data-emoji="speech_balloon" data-unicode-name="1F4AC"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F31F" title="star2" data-aliases="" data-emoji="star2" data-unicode-name="1F31F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F61B" title="stuck_out_tongue" data-aliases="" data-emoji="stuck_out_tongue" data-unicode-name="1F61B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F61D" title="stuck_out_tongue_closed_eyes" data-aliases="" data-emoji="stuck_out_tongue_closed_eyes" data-unicode-name="1F61D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F61C" title="stuck_out_tongue_winking_eye" data-aliases="" data-emoji="stuck_out_tongue_winking_eye" data-unicode-name="1F61C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F60E" title="sunglasses" data-aliases="" data-emoji="sunglasses" data-unicode-name="1F60E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F613" title="sweat" data-aliases="" data-emoji="sweat" data-unicode-name="1F613"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4A6" title="sweat_drops" data-aliases="" data-emoji="sweat_drops" data-unicode-name="1F4A6"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F605" title="sweat_smile" data-aliases="" data-emoji="sweat_smile" data-unicode-name="1F605"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4AD" title="thought_balloon" data-aliases="" data-emoji="thought_balloon" data-unicode-name="1F4AD"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F44E" title="thumbsdown" data-aliases=":-1:" data-emoji="thumbsdown" data-unicode-name="1F44E"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F44D" title="thumbsup" data-aliases=":+1:" data-emoji="thumbsup" data-unicode-name="1F44D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F62B" title="tired_face" data-aliases="" data-emoji="tired_face" data-unicode-name="1F62B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F445" title="tongue" data-aliases="" data-emoji="tongue" data-unicode-name="1F445"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F3A9" title="tophat" data-aliases="" data-emoji="tophat" data-unicode-name="1F3A9"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F624" title="triumph" data-aliases="" data-emoji="triumph" data-unicode-name="1F624"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F495" title="two_hearts" data-aliases="" data-emoji="two_hearts" data-unicode-name="1F495"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F46C" title="two_men_holding_hands" data-aliases="" data-emoji="two_men_holding_hands" data-unicode-name="1F46C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F46D" title="two_women_holding_hands" data-aliases="" data-emoji="two_women_holding_hands" data-unicode-name="1F46D"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F612" title="unamused" data-aliases="" data-emoji="unamused" data-unicode-name="1F612"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-270C" title="v" data-aliases="" data-emoji="v" data-unicode-name="270C"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F6B6" title="walking" data-aliases="" data-emoji="walking" data-unicode-name="1F6B6"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F44B" title="wave" data-aliases="" data-emoji="wave" data-unicode-name="1F44B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F629" title="weary" data-aliases="" data-emoji="weary" data-unicode-name="1F629"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F609" title="wink" data-aliases="" data-emoji="wink" data-unicode-name="1F609"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F469" title="woman" data-aliases="" data-emoji="woman" data-unicode-name="1F469"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F45A" title="womans_clothes" data-aliases="" data-emoji="womans_clothes" data-unicode-name="1F45A"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F452" title="womans_hat" data-aliases="" data-emoji="womans_hat" data-unicode-name="1F452"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F61F" title="worried" data-aliases="" data-emoji="worried" data-unicode-name="1F61F"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F49B" title="yellow_heart" data-aliases="" data-emoji="yellow_heart" data-unicode-name="1F49B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F60B" title="yum" data-aliases="" data-emoji="yum" data-unicode-name="1F60B"></div> + </button> + </li> + <li class='pull-left text-center emoji-menu-list-item'> + <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> + <div class="icon emoji-icon emoji-1F4A4" title="zzz" data-aliases="" data-emoji="zzz" data-unicode-name="1F4A4"></div> + </button> + </li> + </ul> + </div> + </div> +""" diff --git a/spec/javascripts/fixtures/u2f/authenticate.html.haml b/spec/javascripts/fixtures/u2f/authenticate.html.haml new file mode 100644 index 00000000000..859e79a6c9e --- /dev/null +++ b/spec/javascripts/fixtures/u2f/authenticate.html.haml @@ -0,0 +1 @@ += render partial: "u2f/authenticate", locals: { new_user_session_path: "/users/sign_in" } diff --git a/spec/javascripts/fixtures/u2f/register.html.haml b/spec/javascripts/fixtures/u2f/register.html.haml new file mode 100644 index 00000000000..393c0613fd3 --- /dev/null +++ b/spec/javascripts/fixtures/u2f/register.html.haml @@ -0,0 +1 @@ += render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f' } diff --git a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js b/spec/javascripts/graphs/stat_graph_contributors_util_spec.js index 5b992447473..56970e22e34 100644 --- a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js +++ b/spec/javascripts/graphs/stat_graph_contributors_util_spec.js @@ -9,14 +9,14 @@ describe("ContributorsStatGraphUtil", function () { {author_email: "dzaporozhets@email.com", author_name: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 6, deletions: 1}, {author_email: "dzaporozhets@email.com", author_name: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 19, deletions: 3}, {author_email: "dzaporozhets@email.com", author_name: "Dmitriy Zaporozhets", date: "2013-05-08", additions: 29, deletions: 3}] - + var correct_parsed_log = { total: [ {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}], by_author: [ - { + { author_name: "Karlo Soriano", author_email: "karlo@email.com", "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1} }, @@ -132,8 +132,8 @@ describe("ContributorsStatGraphUtil", function () { total: [{date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, {date: "2013-05-08", additions: 54, deletions: 7, commits: 3}], by_author:[ - { - author: "Karlo Soriano", + { + author: "Karlo Soriano", "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1} }, { @@ -161,11 +161,11 @@ describe("ContributorsStatGraphUtil", function () { it("returns the log by author sorted by specified field", function () { var fake_parsed_log = { total: [ - {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, + {date: "2013-05-09", additions: 471, deletions: 0, commits: 1}, {date: "2013-05-08", additions: 54, deletions: 7, commits: 3} ], by_author: [ - { + { author_name: "Karlo Soriano", author_email: "karlo@email.com", "2013-05-09": {date: "2013-05-09", additions: 471, deletions: 0, commits: 1} }, diff --git a/spec/javascripts/new_branch_spec.js.coffee b/spec/javascripts/new_branch_spec.js.coffee index f2ce85efcdc..ce773793817 100644 --- a/spec/javascripts/new_branch_spec.js.coffee +++ b/spec/javascripts/new_branch_spec.js.coffee @@ -1,4 +1,4 @@ -#= require jquery-ui +#= require jquery-ui/autocomplete #= require new_branch_form describe 'Branch', -> diff --git a/spec/javascripts/u2f/authenticate_spec.coffee b/spec/javascripts/u2f/authenticate_spec.coffee new file mode 100644 index 00000000000..e8a2892d678 --- /dev/null +++ b/spec/javascripts/u2f/authenticate_spec.coffee @@ -0,0 +1,52 @@ +#= require u2f/authenticate +#= require u2f/util +#= require u2f/error +#= require u2f +#= require ./mock_u2f_device + +describe 'U2FAuthenticate', -> + U2FUtil.enableTestMode() + fixture.load('u2f/authenticate') + + beforeEach -> + @u2fDevice = new MockU2FDevice + @container = $("#js-authenticate-u2f") + @component = new U2FAuthenticate(@container, {}, "token") + @component.start() + + it 'allows authenticating via a U2F device', -> + setupButton = @container.find("#js-login-u2f-device") + setupMessage = @container.find("p") + expect(setupMessage.text()).toContain('Insert your security key') + expect(setupButton.text()).toBe('Login Via U2F Device') + setupButton.trigger('click') + + inProgressMessage = @container.find("p") + expect(inProgressMessage.text()).toContain("Trying to communicate with your device") + + @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"}) + authenticatedMessage = @container.find("p") + deviceResponse = @container.find('#js-device-response') + expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server") + expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}') + + describe "errors", -> + it "displays an error message", -> + setupButton = @container.find("#js-login-u2f-device") + setupButton.trigger('click') + @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"}) + errorMessage = @container.find("p") + expect(errorMessage.text()).toContain("There was a problem communicating with your device") + + it "allows retrying authentication after an error", -> + setupButton = @container.find("#js-login-u2f-device") + setupButton.trigger('click') + @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"}) + retryButton = @container.find("#js-u2f-try-again") + retryButton.trigger('click') + + setupButton = @container.find("#js-login-u2f-device") + setupButton.trigger('click') + @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"}) + authenticatedMessage = @container.find("p") + expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server") diff --git a/spec/javascripts/u2f/mock_u2f_device.js.coffee b/spec/javascripts/u2f/mock_u2f_device.js.coffee new file mode 100644 index 00000000000..97ed0e83a0e --- /dev/null +++ b/spec/javascripts/u2f/mock_u2f_device.js.coffee @@ -0,0 +1,15 @@ +class @MockU2FDevice + constructor: () -> + window.u2f ||= {} + + window.u2f.register = (appId, registerRequests, signRequests, callback) => + @registerCallback = callback + + window.u2f.sign = (appId, challenges, signRequests, callback) => + @authenticateCallback = callback + + respondToRegisterRequest: (params) => + @registerCallback(params) + + respondToAuthenticateRequest: (params) => + @authenticateCallback(params) diff --git a/spec/javascripts/u2f/register_spec.js.coffee b/spec/javascripts/u2f/register_spec.js.coffee new file mode 100644 index 00000000000..0858abeca1a --- /dev/null +++ b/spec/javascripts/u2f/register_spec.js.coffee @@ -0,0 +1,57 @@ +#= require u2f/register +#= require u2f/util +#= require u2f/error +#= require u2f +#= require ./mock_u2f_device + +describe 'U2FRegister', -> + U2FUtil.enableTestMode() + fixture.load('u2f/register') + + beforeEach -> + @u2fDevice = new MockU2FDevice + @container = $("#js-register-u2f") + @component = new U2FRegister(@container, $("#js-register-u2f-templates"), {}, "token") + @component.start() + + it 'allows registering a U2F device', -> + setupButton = @container.find("#js-setup-u2f-device") + expect(setupButton.text()).toBe('Setup New U2F Device') + setupButton.trigger('click') + + inProgressMessage = @container.children("p") + expect(inProgressMessage.text()).toContain("Trying to communicate with your device") + + @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"}) + registeredMessage = @container.find('p') + deviceResponse = @container.find('#js-device-response') + expect(registeredMessage.text()).toContain("Your device was successfully set up!") + expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}') + + describe "errors", -> + it "doesn't allow the same device to be registered twice (for the same user", -> + setupButton = @container.find("#js-setup-u2f-device") + setupButton.trigger('click') + @u2fDevice.respondToRegisterRequest({errorCode: 4}) + errorMessage = @container.find("p") + expect(errorMessage.text()).toContain("already been registered with us") + + it "displays an error message for other errors", -> + setupButton = @container.find("#js-setup-u2f-device") + setupButton.trigger('click') + @u2fDevice.respondToRegisterRequest({errorCode: "error!"}) + errorMessage = @container.find("p") + expect(errorMessage.text()).toContain("There was a problem communicating with your device") + + it "allows retrying registration after an error", -> + setupButton = @container.find("#js-setup-u2f-device") + setupButton.trigger('click') + @u2fDevice.respondToRegisterRequest({errorCode: "error!"}) + retryButton = @container.find("#U2FTryAgain") + retryButton.trigger('click') + + setupButton = @container.find("#js-setup-u2f-device") + setupButton.trigger('click') + @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"}) + registeredMessage = @container.find("p") + expect(registeredMessage.text()).toContain("Your device was successfully set up!") diff --git a/spec/lib/award_emoji_spec.rb b/spec/lib/gitlab/award_emoji_spec.rb index c3098574292..0f3852b1729 100644 --- a/spec/lib/award_emoji_spec.rb +++ b/spec/lib/gitlab/award_emoji_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' -describe AwardEmoji do +describe Gitlab::AwardEmoji do describe '.urls' do - subject { AwardEmoji.urls } + subject { Gitlab::AwardEmoji.urls } it { is_expected.to be_an_instance_of(Array) } it { is_expected.not_to be_empty } @@ -19,7 +19,7 @@ describe AwardEmoji do describe '.emoji_by_category' do it "only contains known categories" do - undefined_categories = AwardEmoji.emoji_by_category.keys - AwardEmoji::CATEGORIES.keys + undefined_categories = Gitlab::AwardEmoji.emoji_by_category.keys - Gitlab::AwardEmoji::CATEGORIES.keys expect(undefined_categories).to be_empty end end diff --git a/spec/lib/gitlab/badge/build_spec.rb b/spec/lib/gitlab/badge/build_spec.rb index b6f7a2e7ec4..6b2b335d4fc 100644 --- a/spec/lib/gitlab/badge/build_spec.rb +++ b/spec/lib/gitlab/badge/build_spec.rb @@ -42,9 +42,7 @@ describe Gitlab::Badge::Build do end context 'build exists' do - let(:ci_commit) { create(:ci_commit, project: project, sha: sha, ref: branch) } - let!(:build) { create(:ci_build, commit: ci_commit) } - + let!(:build) { create_build(project, sha, branch) } context 'build success' do before { build.success! } @@ -96,6 +94,28 @@ describe Gitlab::Badge::Build do end end + context 'when outdated pipeline for given ref exists' do + before do + build = create_build(project, sha, branch) + build.success! + + old_build = create_build(project, '11eeffdd', branch) + old_build.drop! + end + + it 'does not take outdated pipeline into account' do + expect(badge.to_s).to eq 'build-success' + end + end + + def create_build(project, sha, branch) + ci_commit = create(:ci_commit, project: project, + sha: sha, + ref: branch) + + create(:ci_build, commit: ci_commit) + end + def status_node(data, status) xml = Nokogiri::XML.parse(data) xml.at(%Q{text:contains("#{status}")}) diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb new file mode 100644 index 00000000000..cb3c592f8cd --- /dev/null +++ b/spec/models/award_emoji_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe AwardEmoji, models: true do + describe 'Associations' do + it { is_expected.to belong_to(:awardable) } + it { is_expected.to belong_to(:user) } + end + + describe 'modules' do + it { is_expected.to include_module(Participable) } + end + + describe "validations" do + it { is_expected.to validate_presence_of(:awardable) } + it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_presence_of(:name) } + + # To circumvent a bug in the shoulda matchers + describe "scoped uniqueness validation" do + it "rejects duplicate award emoji" do + user = create(:user) + issue = create(:issue) + create(:award_emoji, user: user, awardable: issue) + new_award = build(:award_emoji, user: user, awardable: issue) + + expect(new_award).not_to be_valid + end + end + end +end diff --git a/spec/models/concerns/awardable_spec.rb b/spec/models/concerns/awardable_spec.rb new file mode 100644 index 00000000000..a371c4a18a9 --- /dev/null +++ b/spec/models/concerns/awardable_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Issue, "Awardable" do + let!(:issue) { create(:issue) } + let!(:award_emoji) { create(:award_emoji, :downvote, awardable: issue) } + + describe "Associations" do + it { is_expected.to have_many(:award_emoji).dependent(:destroy) } + end + + describe "ClassMethods" do + let!(:issue2) { create(:issue) } + + before do + create(:award_emoji, awardable: issue2) + end + + it "orders on upvotes" do + expect(Issue.order_upvotes_desc.to_a).to eq [issue2, issue] + end + + it "orders on downvotes" do + expect(Issue.order_downvotes_desc.to_a).to eq [issue, issue2] + end + end + + describe "#upvotes" do + it "counts the number of upvotes" do + expect(issue.upvotes).to be 0 + end + end + + describe "#downvotes" do + it "counts the number of downvotes" do + expect(issue.downvotes).to be 1 + end + end + + describe "#toggle_award_emoji" do + it "adds an emoji if it isn't awarded yet" do + expect { issue.toggle_award_emoji("thumbsup", award_emoji.user) }.to change { AwardEmoji.count }.by(1) + end + + it "toggles already awarded emoji" do + expect { issue.toggle_award_emoji("thumbsdown", award_emoji.user) }.to change { AwardEmoji.count }.by(-1) + end + end +end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index e9f827e9f50..dd03d64f750 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -12,6 +12,10 @@ describe Issue, "Issuable" do it { is_expected.to have_many(:todos).dependent(:destroy) } end + describe 'Included modules' do + it { is_expected.to include_module(Awardable) } + end + describe "Validation" do before do allow(subject).to receive(:set_iid).and_return(false) @@ -245,8 +249,8 @@ describe Issue, "Issuable" do let(:project) { issue.project } before do - issue.notes.awards.create!(note: "thumbsup", author: user, project: project) - issue.notes.awards.create!(note: "thumbsdown", author: user, project: project) + create(:award_emoji, :upvote, awardable: issue) + create(:award_emoji, :downvote, awardable: issue) end it "returns correct values" do diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index e9d89c9a847..f15e96714b2 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -9,6 +9,16 @@ describe Note, models: true do it { is_expected.to have_many(:todos).dependent(:destroy) } end + describe 'modules' do + subject { described_class } + + it { is_expected.to include_module(Participable) } + it { is_expected.to include_module(Mentionable) } + it { is_expected.to include_module(Awardable) } + + it { is_expected.to include_module(Gitlab::CurrentSettings) } + end + describe 'validation' do it { is_expected.to validate_presence_of(:note) } it { is_expected.to validate_presence_of(:project) } @@ -171,23 +181,6 @@ describe Note, models: true do end end - describe '.grouped_awards' do - before do - create :note, note: "smile", is_award: true - create :note, note: "smile", is_award: true - end - - it "returns grouped hash of notes" do - expect(Note.grouped_awards.keys.size).to eq(3) - expect(Note.grouped_awards["smile"]).to match_array(Note.all) - end - - it "returns thumbsup and thumbsdown always" do - expect(Note.grouped_awards["thumbsup"]).to match_array(Note.none) - expect(Note.grouped_awards["thumbsdown"]).to match_array(Note.none) - end - end - describe "editable?" do it "returns true" do note = build(:note) @@ -198,11 +191,6 @@ describe Note, models: true do note = build(:note, system: true) expect(note.editable?).to be_falsy end - - it "returns false" do - note = build(:note, is_award: true, note: "smiley") - expect(note.editable?).to be_falsy - end end describe "cross_reference_not_visible_for?" do @@ -229,29 +217,6 @@ describe Note, models: true do end end - describe "set_award!" do - let(:merge_request) { create :merge_request } - - it "converts aliases to actual name" do - note = create(:note, note: ":+1:", - noteable: merge_request, - project: merge_request.project) - - expect(note.reload.note).to eq("thumbsup") - end - - it "is not an award emoji when comment is on a diff" do - note = create(:note_on_merge_request_diff, note: ":blowfish:", - noteable: merge_request, - project: merge_request.project, - line_code: "11d5d2e667e9da4f7f610f81d86c974b146b13bd_0_2") - note = note.reload - - expect(note.note).to eq(":blowfish:") - expect(note.is_award?).to be_falsy - end - end - describe 'clear_blank_line_code!' do it 'clears a blank line code before validation' do note = build(:note, line_code: ' ') diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 548bec364f8..6ea8bf9bbe1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -30,6 +30,7 @@ describe User, models: true do it { is_expected.to have_one(:abuse_report) } it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } + it { is_expected.to have_many(:award_emoji).dependent(:destroy) } end describe 'validations' do @@ -120,6 +121,66 @@ describe User, models: true do end end + describe "scopes" do + describe ".with_two_factor" do + it "returns users with 2fa enabled via OTP" do + user_with_2fa = create(:user, :two_factor_via_otp) + user_without_2fa = create(:user) + users_with_two_factor = User.with_two_factor.pluck(:id) + + expect(users_with_two_factor).to include(user_with_2fa.id) + expect(users_with_two_factor).not_to include(user_without_2fa.id) + end + + it "returns users with 2fa enabled via U2F" do + user_with_2fa = create(:user, :two_factor_via_u2f) + user_without_2fa = create(:user) + users_with_two_factor = User.with_two_factor.pluck(:id) + + expect(users_with_two_factor).to include(user_with_2fa.id) + expect(users_with_two_factor).not_to include(user_without_2fa.id) + end + + it "returns users with 2fa enabled via OTP and U2F" do + user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f) + user_without_2fa = create(:user) + users_with_two_factor = User.with_two_factor.pluck(:id) + + expect(users_with_two_factor).to eq([user_with_2fa.id]) + expect(users_with_two_factor).not_to include(user_without_2fa.id) + end + end + + describe ".without_two_factor" do + it "excludes users with 2fa enabled via OTP" do + user_with_2fa = create(:user, :two_factor_via_otp) + user_without_2fa = create(:user) + users_without_two_factor = User.without_two_factor.pluck(:id) + + expect(users_without_two_factor).to include(user_without_2fa.id) + expect(users_without_two_factor).not_to include(user_with_2fa.id) + end + + it "excludes users with 2fa enabled via U2F" do + user_with_2fa = create(:user, :two_factor_via_u2f) + user_without_2fa = create(:user) + users_without_two_factor = User.without_two_factor.pluck(:id) + + expect(users_without_two_factor).to include(user_without_2fa.id) + expect(users_without_two_factor).not_to include(user_with_2fa.id) + end + + it "excludes users with 2fa enabled via OTP and U2F" do + user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f) + user_without_2fa = create(:user) + users_without_two_factor = User.without_two_factor.pluck(:id) + + expect(users_without_two_factor).to include(user_without_2fa.id) + expect(users_without_two_factor).not_to include(user_with_2fa.id) + end + end + end + describe "Respond to" do it { is_expected.to respond_to(:is_admin?) } it { is_expected.to respond_to(:name) } diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 37ab9cc8cfe..bb926172593 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -249,7 +249,6 @@ describe API::API, api: true do expect(json_response['milestone']).to be_a Hash expect(json_response['assignee']).to be_a Hash expect(json_response['author']).to be_a Hash - expect(json_response['user_notes_count']).to be(1) end it "should return a project issue by id" do diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 5aa98ec4014..10d22189c8d 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -138,7 +138,6 @@ describe API::API, api: true do expect(json_response['work_in_progress']).to be_falsy expect(json_response['merge_when_build_succeeds']).to be_falsy expect(json_response['merge_status']).to eq('can_be_merged') - expect(json_response['user_notes_count']).to be(2) end it "should return merge_request" do diff --git a/spec/services/issues/bulk_update_service_spec.rb b/spec/services/issues/bulk_update_service_spec.rb index 454d5849495..4a689e64dc5 100644 --- a/spec/services/issues/bulk_update_service_spec.rb +++ b/spec/services/issues/bulk_update_service_spec.rb @@ -1,114 +1,265 @@ require 'spec_helper' describe Issues::BulkUpdateService, services: true do - let(:issue) { create(:issue, project: @project) } - - before do - @user = create :user - opts = { - name: "GitLab", - namespace: @user.namespace - } - @project = Projects::CreateService.new(@user, opts).execute - end + let(:user) { create(:user) } + let(:project) { Projects::CreateService.new(user, namespace: user.namespace, name: 'test').execute } - describe :close_issue do + let!(:result) { Issues::BulkUpdateService.new(project, user, params).execute } - before do - @issues = create_list(:issue, 5, project: @project) - @params = { + describe :close_issue do + let(:issues) { create_list(:issue, 5, project: project) } + let(:params) do + { state_event: 'close', - issues_ids: @issues.map(&:id).join(",") + issues_ids: issues.map(&:id).join(',') } end - it do - result = Issues::BulkUpdateService.new(@project, @user, @params).execute + it 'succeeds and returns the correct number of issues updated' do expect(result[:success]).to be_truthy - expect(result[:count]).to eq(@issues.count) - - expect(@project.issues.opened).to be_empty - expect(@project.issues.closed).not_to be_empty + expect(result[:count]).to eq(issues.count) end + it 'closes all the issues passed' do + expect(project.issues.opened).to be_empty + expect(project.issues.closed).not_to be_empty + end end describe :reopen_issues do - before do - @issues = create_list(:closed_issue, 5, project: @project) - @params = { + let(:issues) { create_list(:closed_issue, 5, project: project) } + let(:params) do + { state_event: 'reopen', - issues_ids: @issues.map(&:id).join(",") + issues_ids: issues.map(&:id).join(',') } end - it do - result = Issues::BulkUpdateService.new(@project, @user, @params).execute + it 'succeeds and returns the correct number of issues updated' do expect(result[:success]).to be_truthy - expect(result[:count]).to eq(@issues.count) - - expect(@project.issues.closed).to be_empty - expect(@project.issues.opened).not_to be_empty + expect(result[:count]).to eq(issues.count) end + it 'reopens all the issues passed' do + expect(project.issues.closed).to be_empty + expect(project.issues.opened).not_to be_empty + end end - describe :update_assignee do + describe 'updating assignee' do + let(:issue) do + create(:issue, project: project) { |issue| issue.update_attributes(assignee: user) } + end - before do - @new_assignee = create :user - @params = { - issues_ids: issue.id.to_s, - assignee_id: @new_assignee.id + let(:params) do + { + assignee_id: assignee_id, + issues_ids: issue.id.to_s } end - it do - result = Issues::BulkUpdateService.new(@project, @user, @params).execute - expect(result[:success]).to be_truthy - expect(result[:count]).to eq(1) + context 'when the new assignee ID is a valid user' do + let(:new_assignee) { create(:user) } + let(:assignee_id) { new_assignee.id } - expect(@project.issues.first.assignee).to eq(@new_assignee) - end + it 'succeeds' do + expect(result[:success]).to be_truthy + expect(result[:count]).to eq(1) + end - it 'allows mass-unassigning' do - @project.issues.first.update_attribute(:assignee, @new_assignee) - expect(@project.issues.first.assignee).not_to be_nil + it 'updates the assignee to the use ID passed' do + expect(issue.reload.assignee).to eq(new_assignee) + end + end - @params[:assignee_id] = -1 + context 'when the new assignee ID is -1' do + let(:assignee_id) { -1 } - Issues::BulkUpdateService.new(@project, @user, @params).execute - expect(@project.issues.first.assignee).to be_nil + it 'unassigns the issues' do + expect(issue.reload.assignee).to be_nil + end end - it 'does not unassign when assignee_id is not present' do - @project.issues.first.update_attribute(:assignee, @new_assignee) - expect(@project.issues.first.assignee).not_to be_nil - - @params[:assignee_id] = '' + context 'when the new assignee ID is not present' do + let(:assignee_id) { nil } - Issues::BulkUpdateService.new(@project, @user, @params).execute - expect(@project.issues.first.assignee).not_to be_nil + it 'does not unassign' do + expect(issue.reload.assignee).to eq(user) + end end end - describe :update_milestone do + describe 'updating milestones' do + let(:issue) { create(:issue, project: project) } + let(:milestone) { create(:milestone, project: project) } - before do - @milestone = create(:milestone, project: @project) - @params = { + let(:params) do + { issues_ids: issue.id.to_s, - milestone_id: @milestone.id + milestone_id: milestone.id } end - it do - result = Issues::BulkUpdateService.new(@project, @user, @params).execute + it 'succeeds' do expect(result[:success]).to be_truthy expect(result[:count]).to eq(1) + end - expect(@project.issues.first.milestone).to eq(@milestone) + it 'updates the issue milestone' do + expect(project.issues.first.milestone).to eq(milestone) end end + describe 'updating labels' do + def create_issue_with_labels(labels) + create(:issue, project: project) { |issue| issue.update_attributes(labels: labels) } + end + + let(:bug) { create(:label, project: project) } + let(:regression) { create(:label, project: project) } + let(:merge_requests) { create(:label, project: project) } + + let(:issue_all_labels) { create_issue_with_labels([bug, regression, merge_requests]) } + let(:issue_bug_and_regression) { create_issue_with_labels([bug, regression]) } + let(:issue_bug_and_merge_requests) { create_issue_with_labels([bug, merge_requests]) } + let(:issue_no_labels) { create(:issue, project: project) } + let(:issues) { [issue_all_labels, issue_bug_and_regression, issue_bug_and_merge_requests, issue_no_labels] } + + let(:labels) { [] } + let(:add_labels) { [] } + let(:remove_labels) { [] } + + let(:params) do + { + label_ids: labels.map(&:id), + add_label_ids: add_labels.map(&:id), + remove_label_ids: remove_labels.map(&:id), + issues_ids: issues.map(&:id).join(',') + } + end + + context 'when label_ids are passed' do + let(:issues) { [issue_all_labels, issue_no_labels] } + let(:labels) { [bug, regression] } + + it 'updates the labels of all issues passed to the labels passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(eq(labels.map(&:id))) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + + context 'when those label IDs are empty' do + let(:labels) { [] } + + it 'updates the issues passed to have no labels' do + expect(issues.map(&:reload).map(&:label_ids)).to all(be_empty) + end + end + end + + context 'when add_label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } + let(:add_labels) { [bug, regression, merge_requests] } + + it 'adds those label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(*add_labels.map(&:id))) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + + context 'when remove_label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } + let(:remove_labels) { [bug, regression, merge_requests] } + + it 'removes those label IDs from all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(be_empty) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + + context 'when add_label_ids and remove_label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_merge_requests, issue_no_labels] } + let(:add_labels) { [bug] } + let(:remove_labels) { [merge_requests] } + + it 'adds the label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) + end + + it 'removes the label IDs from all issues passed' do + expect(issues.map(&:reload).map(&:label_ids).flatten).not_to include(merge_requests.id) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + + context 'when add_label_ids and label_ids are passed' do + let(:issues) { [issue_all_labels, issue_bug_and_regression, issue_bug_and_merge_requests] } + let(:labels) { [merge_requests] } + let(:add_labels) { [regression] } + + it 'adds the label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(regression.id)) + end + + it 'ignores the label IDs parameter' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) + end + + it 'does not update issues not passed in' do + expect(issue_no_labels.label_ids).to be_empty + end + end + + context 'when remove_label_ids and label_ids are passed' do + let(:issues) { [issue_no_labels, issue_bug_and_regression] } + let(:labels) { [merge_requests] } + let(:remove_labels) { [regression] } + + it 'remove the label IDs from all issues passed' do + expect(issues.map(&:reload).map(&:label_ids).flatten).not_to include(regression.id) + end + + it 'ignores the label IDs parameter' do + expect(issues.map(&:reload).map(&:label_ids).flatten).not_to include(merge_requests.id) + end + + it 'does not update issues not passed in' do + expect(issue_all_labels.label_ids).to contain_exactly(bug.id, regression.id, merge_requests.id) + end + end + + context 'when add_label_ids, remove_label_ids, and label_ids are passed' do + let(:issues) { [issue_bug_and_merge_requests, issue_no_labels] } + let(:labels) { [regression] } + let(:add_labels) { [bug] } + let(:remove_labels) { [merge_requests] } + + it 'adds the label IDs to all issues passed' do + expect(issues.map(&:reload).map(&:label_ids)).to all(include(bug.id)) + end + + it 'removes the label IDs from all issues passed' do + expect(issues.map(&:reload).map(&:label_ids).flatten).not_to include(merge_requests.id) + end + + it 'ignores the label IDs parameter' do + expect(issues.map(&:reload).map(&:label_ids).flatten).not_to include(regression.id) + end + + it 'does not update issues not passed in' do + expect(issue_bug_and_regression.label_ids).to contain_exactly(bug.id, regression.id) + end + end + end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 95fe6c2400a..93bf0f64963 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -39,6 +39,7 @@ describe Issues::MoveService, services: true do let!(:milestone2) do create(:milestone, project_id: new_project.id, title: 'v9.0') end + let!(:award_emoji) { create(:award_emoji, awardable: old_issue) } let!(:new_issue) { move_service.execute(old_issue, new_project) } end @@ -115,6 +116,10 @@ describe Issues::MoveService, services: true do it 'preserves create time' do expect(old_issue.created_at).to eq new_issue.created_at end + + it 'moves the award emoji' do + expect(old_issue.award_emoji.first.name).to eq new_issue.reload.award_emoji.first.name + end end context 'issue with notes' do diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index be19be17151..dacbcd8fb46 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -1,3 +1,4 @@ +# coding: utf-8 require 'spec_helper' describe Issues::UpdateService, services: true do @@ -273,5 +274,50 @@ describe Issues::UpdateService, services: true do end end end + + context 'updating labels' do + let(:label3) { create(:label, project: project) } + let(:result) { Issues::UpdateService.new(project, user, params).execute(issue).reload } + + context 'when add_label_ids and label_ids are passed' do + let(:params) { { label_ids: [label.id], add_label_ids: [label3.id] } } + + it 'ignores the label_ids parameter' do + expect(result.label_ids).not_to include(label.id) + end + + it 'adds the passed labels' do + expect(result.label_ids).to include(label3.id) + end + end + + context 'when remove_label_ids and label_ids are passed' do + let(:params) { { label_ids: [], remove_label_ids: [label.id] } } + + before { issue.update_attributes(labels: [label, label3]) } + + it 'ignores the label_ids parameter' do + expect(result.label_ids).not_to be_empty + end + + it 'removes the passed labels' do + expect(result.label_ids).not_to include(label.id) + end + end + + context 'when add_label_ids and remove_label_ids are passed' do + let(:params) { { add_label_ids: [label3.id], remove_label_ids: [label.id] } } + + before { issue.update_attributes(labels: [label]) } + + it 'adds the passed labels' do + expect(result.label_ids).to include(label3.id) + end + + it 'removes the passed labels' do + expect(result.label_ids).not_to include(label.id) + end + end + end end end diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index ff23f13e1cb..35f576874b8 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -14,7 +14,7 @@ describe Notes::CreateService, services: true do noteable_type: 'Issue', noteable_id: issue.id } - + @note = Notes::CreateService.new(project, user, opts).execute end @@ -28,18 +28,16 @@ describe Notes::CreateService, services: true do project.team << [user, :master] end - it "creates emoji note" do + it "creates an award emoji" do opts = { note: ':smile: ', noteable_type: 'Issue', noteable_id: issue.id } + note = Notes::CreateService.new(project, user, opts).execute - @note = Notes::CreateService.new(project, user, opts).execute - - expect(@note).to be_valid - expect(@note.note).to eq('smile') - expect(@note.is_award).to be_truthy + expect(note).to be_valid + expect(note.name).to eq('smile') end it "creates regular note if emoji name is invalid" do @@ -48,12 +46,22 @@ describe Notes::CreateService, services: true do noteable_type: 'Issue', noteable_id: issue.id } + note = Notes::CreateService.new(project, user, opts).execute + + expect(note).to be_valid + expect(note.note).to eq(opts[:note]) + end + + it "normalizes the emoji name" do + opts = { + note: ':+1:', + noteable_type: 'Issue', + noteable_id: issue.id + } - @note = Notes::CreateService.new(project, user, opts).execute + expect_any_instance_of(TodoService).to receive(:new_award_emoji).with(issue, user) - expect(@note).to be_valid - expect(@note.note).to eq(opts[:note]) - expect(@note.is_award).to be_falsy + Notes::CreateService.new(project, user, opts).execute end end end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 42147736532..6e7ecbd39ba 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -156,7 +156,6 @@ describe TodoService, services: true do let(:note_on_commit) { create(:note_on_commit, project: project, author: john_doe, note: mentions) } let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project, note: mentions) } let(:note_on_project_snippet) { create(:note_on_project_snippet, project: project, author: john_doe, note: mentions) } - let(:award_note) { create(:note, :award, project: project, noteable: issue, author: john_doe, note: 'thumbsup') } let(:system_note) { create(:system_note, project: project, noteable: issue) } it 'mark related pending todos to the noteable for the note author as done' do @@ -169,13 +168,6 @@ describe TodoService, services: true do expect(second_todo.reload).to be_done end - it 'mark related pending todos to the noteable for the award note author as done' do - service.new_note(award_note, john_doe) - - expect(first_todo.reload).to be_done - expect(second_todo.reload).to be_done - end - it 'does not mark related pending todos it is a system note' do service.new_note(system_note, john_doe) @@ -306,6 +298,15 @@ describe TodoService, services: true do end end + describe '#new_award_emoji' do + it 'marks related pending todos to the target for the user as done' do + todo = create(:todo, user: john_doe, project: project, target: mr_assigned, author: author) + service.new_award_emoji(mr_assigned, john_doe) + + expect(todo.reload).to be_done + end + end + describe '#merge_request_build_failed' do it 'creates a pending todo for the merge request author' do service.merge_request_build_failed(mr_unassigned) diff --git a/spec/support/fake_u2f_device.rb b/spec/support/fake_u2f_device.rb new file mode 100644 index 00000000000..553fe9f1fbc --- /dev/null +++ b/spec/support/fake_u2f_device.rb @@ -0,0 +1,36 @@ +class FakeU2fDevice + def initialize(page) + @page = page + end + + def respond_to_u2f_registration + app_id = @page.evaluate_script('gon.u2f.app_id') + challenges = @page.evaluate_script('gon.u2f.challenges') + + json_response = u2f_device(app_id).register_response(challenges[0]) + + @page.execute_script(" + u2f.register = function(appId, registerRequests, signRequests, callback) { + callback(#{json_response}); + }; + ") + end + + def respond_to_u2f_authentication + app_id = @page.evaluate_script('gon.u2f.app_id') + challenges = @page.evaluate_script('gon.u2f.challenges') + json_response = u2f_device(app_id).sign_response(challenges[0]) + + @page.execute_script(" + u2f.sign = function(appId, challenges, signRequests, callback) { + callback(#{json_response}); + }; + ") + end + + private + + def u2f_device(app_id) + @u2f_device ||= U2F::FakeU2F.new(app_id) + end +end diff --git a/vendor/assets/javascripts/task_list.js.coffee b/vendor/assets/javascripts/task_list.js.coffee new file mode 100644 index 00000000000..584751af8ea --- /dev/null +++ b/vendor/assets/javascripts/task_list.js.coffee @@ -0,0 +1,258 @@ +# The MIT License (MIT) +# +# Copyright (c) 2014 GitHub, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# TaskList Behavior +# +#= provides tasklist:enabled +#= provides tasklist:disabled +#= provides tasklist:change +#= provides tasklist:changed +# +# +# Enables Task List update behavior. +# +# ### Example Markup +# +# <div class="js-task-list-container"> +# <ul class="task-list"> +# <li class="task-list-item"> +# <input type="checkbox" class="js-task-list-item-checkbox" disabled /> +# text +# </li> +# </ul> +# <form> +# <textarea class="js-task-list-field">- [ ] text</textarea> +# </form> +# </div> +# +# ### Specification +# +# TaskLists MUST be contained in a `(div).js-task-list-container`. +# +# TaskList Items SHOULD be an a list (`UL`/`OL`) element. +# +# Task list items MUST match `(input).task-list-item-checkbox` and MUST be +# `disabled` by default. +# +# TaskLists MUST have a `(textarea).js-task-list-field` form element whose +# `value` attribute is the source (Markdown) to be udpated. The source MUST +# follow the syntax guidelines. +# +# TaskList updates trigger `tasklist:change` events. If the change is +# successful, `tasklist:changed` is fired. The change can be canceled. +# +# jQuery is required. +# +# ### Methods +# +# `.taskList('enable')` or `.taskList()` +# +# Enables TaskList updates for the container. +# +# `.taskList('disable')` +# +# Disables TaskList updates for the container. +# +## ### Events +# +# `tasklist:enabled` +# +# Fired when the TaskList is enabled. +# +# * **Synchronicity** Sync +# * **Bubbles** Yes +# * **Cancelable** No +# * **Target** `.js-task-list-container` +# +# `tasklist:disabled` +# +# Fired when the TaskList is disabled. +# +# * **Synchronicity** Sync +# * **Bubbles** Yes +# * **Cancelable** No +# * **Target** `.js-task-list-container` +# +# `tasklist:change` +# +# Fired before the TaskList item change takes affect. +# +# * **Synchronicity** Sync +# * **Bubbles** Yes +# * **Cancelable** Yes +# * **Target** `.js-task-list-field` +# +# `tasklist:changed` +# +# Fired once the TaskList item change has taken affect. +# +# * **Synchronicity** Sync +# * **Bubbles** Yes +# * **Cancelable** No +# * **Target** `.js-task-list-field` +# +# ### NOTE +# +# Task list checkboxes are rendered as disabled by default because rendered +# user content is cached without regard for the viewer. + +incomplete = "[ ]" +complete = "[x]" + +# Escapes the String for regular expression matching. +escapePattern = (str) -> + str. + replace(/([\[\]])/g, "\\$1"). # escape square brackets + replace(/\s/, "\\s"). # match all white space + replace("x", "[xX]") # match all cases + +incompletePattern = /// + #{escapePattern(incomplete)} +/// +completePattern = /// + #{escapePattern(complete)} +/// + +# Pattern used to identify all task list items. +# Useful when you need iterate over all items. +itemPattern = /// + ^ + (?: # prefix, consisting of + \s* # optional leading whitespace + (?:>\s*)* # zero or more blockquotes + (?:[-+*]|(?:\d+\.)) # list item indicator + ) + \s* # optional whitespace prefix + ( # checkbox + #{escapePattern(complete)}| + #{escapePattern(incomplete)} + ) + \s+ # is followed by whitespace + (?! + \(.*?\) # is not part of a [foo](url) link + ) + (?= # and is followed by zero or more links + (?:\[.*?\]\s*(?:\[.*?\]|\(.*?\))\s*)* + (?:[^\[]|$) # and either a non-link or the end of the string + ) +/// + +# Used to filter out code fences from the source for comparison only. +# http://rubular.com/r/x5EwZVrloI +# Modified slightly due to issues with JS +codeFencesPattern = /// + ^`{3} # ``` + (?:\s*\w+)? # followed by optional language + [\S\s] # whitespace + .* # code + [\S\s] # whitespace + ^`{3}$ # ``` +///mg + +# Used to filter out potential mismatches (items not in lists). +# http://rubular.com/r/OInl6CiePy +itemsInParasPattern = /// + ^ + ( + #{escapePattern(complete)}| + #{escapePattern(incomplete)} + ) + .+ + $ +///g + +# Given the source text, updates the appropriate task list item to match the +# given checked value. +# +# Returns the updated String text. +updateTaskListItem = (source, itemIndex, checked) -> + clean = source.replace(/\r/g, '').replace(codeFencesPattern, ''). + replace(itemsInParasPattern, '').split("\n") + index = 0 + result = for line in source.split("\n") + if line in clean && line.match(itemPattern) + index += 1 + if index == itemIndex + line = + if checked + line.replace(incompletePattern, complete) + else + line.replace(completePattern, incomplete) + line + result.join("\n") + +# Updates the $field value to reflect the state of $item. +# Triggers the `tasklist:change` event before the value has changed, and fires +# a `tasklist:changed` event once the value has changed. +updateTaskList = ($item) -> + $container = $item.closest '.js-task-list-container' + $field = $container.find '.js-task-list-field' + index = 1 + $container.find('.task-list-item-checkbox').index($item) + checked = $item.prop 'checked' + + event = $.Event 'tasklist:change' + $field.trigger event, [index, checked] + + unless event.isDefaultPrevented() + $field.val updateTaskListItem($field.val(), index, checked) + $field.trigger 'change' + $field.trigger 'tasklist:changed', [index, checked] + +# When the task list item checkbox is updated, submit the change +$(document).on 'change', '.task-list-item-checkbox', -> + updateTaskList $(this) + +# Enables TaskList item changes. +enableTaskList = ($container) -> + if $container.find('.js-task-list-field').length > 0 + $container. + find('.task-list-item').addClass('enabled'). + find('.task-list-item-checkbox').attr('disabled', null) + $container.addClass('is-task-list-enabled'). + trigger 'tasklist:enabled' + +# Enables a collection of TaskList containers. +enableTaskLists = ($containers) -> + for container in $containers + enableTaskList $(container) + +# Disable TaskList item changes. +disableTaskList = ($container) -> + $container. + find('.task-list-item').removeClass('enabled'). + find('.task-list-item-checkbox').attr('disabled', 'disabled') + $container.removeClass('is-task-list-enabled'). + trigger 'tasklist:disabled' + +# Disables a collection of TaskList containers. +disableTaskLists = ($containers) -> + for container in $containers + disableTaskList $(container) + +$.fn.taskList = (method) -> + $container = $(this).closest('.js-task-list-container') + + methods = + enable: enableTaskLists + disable: disableTaskLists + + methods[method || 'enable']($container) diff --git a/vendor/assets/javascripts/u2f.js b/vendor/assets/javascripts/u2f.js new file mode 100644 index 00000000000..e666b136051 --- /dev/null +++ b/vendor/assets/javascripts/u2f.js @@ -0,0 +1,748 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. +u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array<u2f.Transport>} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return $.inArray(navigator.platform, ["iPhone", "iPad", "iPod"]) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array<u2f.SignRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + }; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } + }; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); + }; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse)) + * |function((u2f.Error|u2f.SignResponse)))>} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.<u2f.Response>} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +};
\ No newline at end of file |
