summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.rubocop.yml4
-rw-r--r--CHANGELOG13
-rw-r--r--CONTRIBUTING.md4
-rw-r--r--Gemfile5
-rw-r--r--Gemfile.lock10
-rw-r--r--app/assets/javascripts/application.js.coffee4
-rw-r--r--app/assets/javascripts/awards_handler.coffee425
-rw-r--r--app/assets/javascripts/dispatcher.js.coffee3
-rw-r--r--app/assets/javascripts/due_date_select.js.coffee5
-rw-r--r--app/assets/javascripts/flash.js.coffee2
-rw-r--r--app/assets/javascripts/gl_dropdown.js.coffee95
-rw-r--r--app/assets/javascripts/issues-bulk-assignment.js.coffee109
-rw-r--r--app/assets/javascripts/labels_select.js.coffee70
-rw-r--r--app/assets/javascripts/lib/emoji_aliases.js.coffee.erb2
-rw-r--r--app/assets/javascripts/milestone_select.js.coffee4
-rw-r--r--app/assets/javascripts/notes.js.coffee7
-rw-r--r--app/assets/javascripts/search_autocomplete.js.coffee13
-rw-r--r--app/assets/javascripts/shortcuts_issuable.coffee18
-rw-r--r--app/assets/javascripts/u2f/authenticate.js.coffee63
-rw-r--r--app/assets/javascripts/u2f/error.js.coffee13
-rw-r--r--app/assets/javascripts/u2f/register.js.coffee63
-rw-r--r--app/assets/javascripts/u2f/util.js.coffee.erb15
-rw-r--r--app/assets/javascripts/users_select.js.coffee2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss11
-rw-r--r--app/assets/stylesheets/framework/mixins.scss8
-rw-r--r--app/assets/stylesheets/framework/mobile.scss4
-rw-r--r--app/assets/stylesheets/framework/timeline.scss2
-rw-r--r--app/assets/stylesheets/pages/awards.scss15
-rw-r--r--app/assets/stylesheets/pages/builds.scss7
-rw-r--r--app/assets/stylesheets/pages/note_form.scss33
-rw-r--r--app/assets/stylesheets/pages/notes.scss58
-rw-r--r--app/assets/stylesheets/pages/search.scss2
-rw-r--r--app/controllers/application_controller.rb15
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb59
-rw-r--r--app/controllers/concerns/toggle_award_emoji.rb31
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb45
-rw-r--r--app/controllers/projects/artifacts_controller.rb2
-rw-r--r--app/controllers/projects/builds_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb16
-rw-r--r--app/controllers/projects/merge_requests_controller.rb6
-rw-r--r--app/controllers/projects/notes_controller.rb42
-rw-r--r--app/controllers/projects_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb36
-rw-r--r--app/finders/notes_finder.rb4
-rw-r--r--app/helpers/auth_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb13
-rw-r--r--app/helpers/issues_helper.rb14
-rw-r--r--app/models/award_emoji.rb26
-rw-r--r--app/models/concerns/awardable.rb81
-rw-r--r--app/models/concerns/issuable.rb32
-rw-r--r--app/models/legacy_diff_note.rb4
-rw-r--r--app/models/note.rb53
-rw-r--r--app/models/project_services/irker_service.rb2
-rw-r--r--app/models/u2f_registration.rb40
-rw-r--r--app/models/user.rb46
-rw-r--r--app/services/issuable_base_service.rb34
-rw-r--r--app/services/issues/bulk_update_service.rb6
-rw-r--r--app/services/issues/move_service.rb9
-rw-r--r--app/services/notes/create_service.rb7
-rw-r--r--app/services/notes/post_process_service.rb2
-rw-r--r--app/services/notification_service.rb3
-rw-r--r--app/services/oauth2/access_token_validation_service.rb1
-rw-r--r--app/services/todo_service.rb8
-rw-r--r--app/views/award_emoji/_awards_block.html.haml18
-rw-r--r--app/views/devise/sessions/two_factor.html.haml21
-rw-r--r--app/views/emojis/index.html.haml4
-rw-r--r--app/views/events/event/_common.html.haml10
-rw-r--r--app/views/help/_shortcuts.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml2
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/devise.html.haml1
-rw-r--r--app/views/layouts/devise_empty.html.haml1
-rw-r--r--app/views/layouts/errors.html.haml1
-rw-r--r--app/views/layouts/nav/_project.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml25
-rw-r--r--app/views/profiles/two_factor_auths/new.html.haml39
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml69
-rw-r--r--app/views/projects/_md_preview.html.haml6
-rw-r--r--app/views/projects/issues/_issue.html.haml4
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/labels/_label.html.haml3
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml2
-rw-r--r--app/views/projects/merge_requests/_merge_requests.html.haml1
-rw-r--r--app/views/projects/merge_requests/_show.html.haml4
-rw-r--r--app/views/projects/notes/_note.html.haml12
-rw-r--r--app/views/shared/issuable/_filter.html.haml6
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml17
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml10
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml15
-rw-r--r--app/views/u2f/_authenticate.html.haml28
-rw-r--r--app/views/u2f/_register.html.haml31
-rw-r--r--app/views/votes/_votes_block.html.haml30
-rw-r--r--config/initializers/inflections.rb4
-rw-r--r--config/routes.rb10
-rw-r--r--db/migrate/20160416180807_add_award_emoji.rb14
-rw-r--r--db/migrate/20160416182152_convert_award_note_to_emoji_award.rb9
-rw-r--r--db/migrate/20160416190505_remove_note_is_award.rb5
-rw-r--r--db/migrate/20160425045124_create_u2f_registrations.rb13
-rw-r--r--db/schema.rb29
-rw-r--r--doc/api/builds.md24
-rw-r--r--doc/profile/2fa_u2f_authenticate.pngbin0 -> 54413 bytes
-rw-r--r--doc/profile/2fa_u2f_register.pngbin0 -> 112414 bytes
-rw-r--r--doc/profile/two_factor_authentication.md63
-rw-r--r--features/project/issues/issues.feature7
-rw-r--r--features/project/merge_requests.feature2
-rw-r--r--features/steps/project/issues/filter_labels.rb2
-rw-r--r--features/steps/project/issues/issues.rb12
-rw-r--r--features/steps/project/merge_requests.rb10
-rw-r--r--features/steps/shared/issuable.rb16
-rw-r--r--lib/api/entities.rb10
-rw-r--r--lib/api/repositories.rb6
-rw-r--r--lib/award_emoji.rb84
-rw-r--r--lib/backup/database.rb4
-rw-r--r--lib/backup/manager.rb30
-rw-r--r--lib/backup/repository.rb26
-rw-r--r--lib/gitlab/award_emoji.rb84
-rw-r--r--lib/gitlab/key_fingerprint.rb6
-rw-r--r--lib/gitlab/ldap/config.rb1
-rw-r--r--lib/gitlab/seeder.rb2
-rw-r--r--lib/tasks/gitlab/backup.rake80
-rw-r--r--lib/tasks/gitlab/check.rake178
-rw-r--r--lib/tasks/gitlab/cleanup.rake18
-rw-r--r--lib/tasks/gitlab/db.rake8
-rw-r--r--lib/tasks/gitlab/git.rake8
-rw-r--r--lib/tasks/gitlab/import.rake14
-rw-r--r--lib/tasks/gitlab/info.rake26
-rw-r--r--lib/tasks/gitlab/shell.rake4
-rw-r--r--lib/tasks/gitlab/task_helpers.rake10
-rw-r--r--lib/tasks/gitlab/two_factor.rake8
-rw-r--r--lib/tasks/gitlab/update_commit_count.rake6
-rw-r--r--lib/tasks/gitlab/update_gitignore.rake4
-rw-r--r--lib/tasks/gitlab/web_hook.rake6
-rw-r--r--lib/tasks/migrate/migrate_iids.rake6
-rw-r--r--lib/tasks/spinach.rake2
-rw-r--r--spec/controllers/groups_controller_spec.rb12
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb14
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb16
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb36
-rw-r--r--spec/controllers/sessions_controller_spec.rb26
-rw-r--r--spec/factories/award_emoji.rb12
-rw-r--r--spec/factories/notes.rb6
-rw-r--r--spec/factories/u2f_registrations.rb8
-rw-r--r--spec/factories/users.rb14
-rw-r--r--spec/features/admin/admin_users_spec.rb10
-rw-r--r--spec/features/builds_spec.rb168
-rw-r--r--spec/features/issues/award_emoji_spec.rb2
-rw-r--r--spec/features/issues/award_spec.rb49
-rw-r--r--spec/features/issues/bulk_assigment_labels_spec.rb196
-rw-r--r--spec/features/issues/update_issues_spec.rb23
-rw-r--r--spec/features/issues_spec.rb10
-rw-r--r--spec/features/login_spec.rb26
-rw-r--r--spec/features/merge_requests/award_spec.rb49
-rw-r--r--spec/features/notes_on_merge_requests_spec.rb25
-rw-r--r--spec/features/u2f_spec.rb239
-rw-r--r--spec/helpers/issues_helper_spec.rb11
-rw-r--r--spec/javascripts/awards_handler_spec.js.coffee202
-rw-r--r--spec/javascripts/behaviors/quick_submit_spec.js.coffee22
-rw-r--r--spec/javascripts/fixtures/awards_handler.html.haml52
-rw-r--r--spec/javascripts/fixtures/behaviors/quick_submit.html.haml2
-rw-r--r--spec/javascripts/fixtures/emoji_menu.coffee957
-rw-r--r--spec/javascripts/fixtures/u2f/authenticate.html.haml1
-rw-r--r--spec/javascripts/fixtures/u2f/register.html.haml1
-rw-r--r--spec/javascripts/graphs/stat_graph_contributors_util_spec.js12
-rw-r--r--spec/javascripts/new_branch_spec.js.coffee2
-rw-r--r--spec/javascripts/u2f/authenticate_spec.coffee52
-rw-r--r--spec/javascripts/u2f/mock_u2f_device.js.coffee15
-rw-r--r--spec/javascripts/u2f/register_spec.js.coffee57
-rw-r--r--spec/lib/gitlab/award_emoji_spec.rb (renamed from spec/lib/award_emoji_spec.rb)6
-rw-r--r--spec/lib/gitlab/badge/build_spec.rb26
-rw-r--r--spec/models/award_emoji_spec.rb30
-rw-r--r--spec/models/concerns/awardable_spec.rb48
-rw-r--r--spec/models/concerns/issuable_spec.rb8
-rw-r--r--spec/models/note_spec.rb55
-rw-r--r--spec/models/user_spec.rb61
-rw-r--r--spec/requests/api/issues_spec.rb1
-rw-r--r--spec/requests/api/merge_requests_spec.rb1
-rw-r--r--spec/services/issues/bulk_update_service_spec.rb279
-rw-r--r--spec/services/issues/move_service_spec.rb5
-rw-r--r--spec/services/issues/update_service_spec.rb46
-rw-r--r--spec/services/notes/create_service_spec.rb30
-rw-r--r--spec/services/todo_service_spec.rb17
-rw-r--r--spec/support/fake_u2f_device.rb36
-rw-r--r--vendor/assets/javascripts/task_list.js.coffee258
-rw-r--r--vendor/assets/javascripts/u2f.js748
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/
diff --git a/Gemfile b/Gemfile
index d9429de786f..38ff536fd71 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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 &#8984; 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
new file mode 100644
index 00000000000..b9138ff60db
--- /dev/null
+++ b/doc/profile/2fa_u2f_authenticate.png
Binary files differ
diff --git a/doc/profile/2fa_u2f_register.png b/doc/profile/2fa_u2f_register.png
new file mode 100644
index 00000000000..15b3683ef73
--- /dev/null
+++ b/doc/profile/2fa_u2f_register.png
Binary files differ
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.
+
+![Two-Factor U2F Setup](2fa_u2f_register.png)
+
## 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.
-![Two-factor authentication on sign in](2fa_auth.png)
+![Two-Factor Authentication on sign in via OTP](2fa_auth.png)
+
+### 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.
+
+![Two-Factor Authentication on sign in via U2F device](2fa_u2f_authenticate.png)
## 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