From 1e7c69044c4260b35ca15358a2ccc7f3c88e96e9 Mon Sep 17 00:00:00 2001 From: cpradio Date: Thu, 13 Oct 2016 12:34:31 -0400 Subject: [PATCH 1/2] FIX: Improve removing advanced filters Prior: Entering `test after:5` and then removing the 5 via the search text field would result in the UI not updating After: UI updates after half a second Removing it from the UI, removes it from the search field immediately. Change the regex to detect filter words. This now matches what happens in search.rb, which gives a lot more flexibility (such as iterating over multiple `in:` terms) Return [] when searchTerm is empty Move .trim() to this.set('searchTerm', searchTerm) so it doesn't run twice (which was very obvious when watching the search term field) More refactoring to make this a bit less complex Update code based on review comments FEATURE: Add common `in:` options --- Gemfile.lock | 38 +- .../components/search-advanced-options.js.es6 | 550 ++++++++------- .../discourse/components/tag-chooser.js.es6 | 1 + .../discourse/helpers/user-avatar.js.es6 | 4 +- .../discourse/routes/app-route-map.js.es6 | 2 +- .../components/search-advanced-options.hbs | 9 +- .../discourse/templates/user/preferences.hbs | 8 +- .../stylesheets/common/base/search.scss | 4 + app/assets/stylesheets/mobile/search.scss | 7 +- app/controllers/user_api_keys_controller.rb | 31 +- app/models/user_api_key.rb | 59 +- app/serializers/user_serializer.rb | 3 +- app/services/post_alerter.rb | 4 +- app/views/user_api_keys/new.html.erb | 12 +- config/locales/client.en.yml | 3 - config/locales/client.fi.yml | 4 + config/locales/client.fr.yml | 2 +- config/locales/client.he.yml | 34 +- config/locales/client.it.yml | 30 + config/locales/client.ro.yml | 92 +-- config/locales/client.ru.yml | 6 +- config/locales/client.tr_TR.yml | 80 ++- config/locales/server.ar.yml | 7 - config/locales/server.bs_BA.yml | 7 - config/locales/server.cs.yml | 1 - config/locales/server.da.yml | 7 - config/locales/server.de.yml | 79 ++- config/locales/server.en.yml | 14 +- config/locales/server.es.yml | 7 - config/locales/server.fa_IR.yml | 7 - config/locales/server.fi.yml | 7 - config/locales/server.fr.yml | 8 +- config/locales/server.he.yml | 28 +- config/locales/server.it.yml | 28 +- config/locales/server.ja.yml | 7 - config/locales/server.ko.yml | 7 - config/locales/server.nb_NO.yml | 1 - config/locales/server.nl.yml | 7 - config/locales/server.pl_PL.yml | 7 - config/locales/server.pt.yml | 7 - config/locales/server.pt_BR.yml | 87 ++- config/locales/server.ro.yml | 650 +++++++++--------- config/locales/server.ru.yml | 7 - config/locales/server.sk.yml | 7 - config/locales/server.sq.yml | 31 +- config/locales/server.sv.yml | 7 - config/locales/server.te.yml | 5 - config/locales/server.tr_TR.yml | 39 +- config/locales/server.vi.yml | 7 - config/locales/server.zh_CN.yml | 7 - config/locales/server.zh_TW.yml | 6 - config/site_settings.yml | 9 +- ...61013012136_add_scopes_to_user_api_keys.rb | 13 + docs/INSTALL-cloud.md | 2 + lib/auth/default_current_user_provider.rb | 19 +- lib/discourse_tagging.rb | 8 +- lib/wizard.rb | 18 +- plugins/poll/config/locales/client.ko.yml | 4 + plugins/poll/config/locales/server.ko.yml | 2 + public/500.ro.html | 3 +- .../default_current_user_provider_spec.rb | 4 +- spec/components/wizard_spec.rb | 7 - .../user_api_keys_controller_spec.rb | 24 +- spec/fabricators/user_api_key_fabricator.rb | 4 +- spec/models/user_api_key_spec.rb | 28 + spec/services/post_alerter_spec.rb | 4 +- .../acceptance/search-full-test.js.es6 | 59 +- 67 files changed, 1284 insertions(+), 996 deletions(-) create mode 100644 db/migrate/20161013012136_add_scopes_to_user_api_keys.rb create mode 100644 spec/models/user_api_key_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index bf92ec28335..8fd86089c45 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -62,16 +62,16 @@ GEM binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) builder (3.2.2) - bullet (5.0.0) + bullet (5.4.2) activesupport (>= 3.0.0) - uniform_notifier (~> 1.9.0) - byebug (8.2.1) + uniform_notifier (~> 1.10.0) + byebug (9.0.6) certified (1.0.0) - coderay (1.1.0) + coderay (1.1.1) concurrent-ruby (1.0.2) connection_pool (2.2.0) crass (1.0.2) - daemons (1.2.3) + daemons (1.2.4) debug_inspector (0.0.2) diff-lcs (1.2.5) discourse-qunit-rails (0.0.9) @@ -96,7 +96,7 @@ GEM ember-source (1.12.2) erubis (2.7.0) eventmachine (1.2.0.1) - excon (0.45.4) + excon (0.53.0) execjs (2.7.0) exifr (1.2.4) fabrication (2.9.8) @@ -164,7 +164,7 @@ GEM mini_portile2 (2.1.0) mini_racer (0.1.3) libv8 (~> 5.0) - minitest (5.9.0) + minitest (5.9.1) mocha (1.1.0) metaclass (~> 0.0.1) mock_redis (0.15.4) @@ -175,9 +175,8 @@ GEM multipart-post (2.0.0) mustache (1.0.3) netrc (0.11.0) - nokogiri (1.6.8) + nokogiri (1.6.8.1) mini_portile2 (~> 2.1.0) - pkg-config (~> 1.1.7) nokogumbo (1.4.7) nokogiri oauth (0.4.7) @@ -226,9 +225,8 @@ GEM redis ruby-openid pg (0.18.4) - pkg-config (1.1.7) progress (3.1.1) - pry (0.10.3) + pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) @@ -236,7 +234,7 @@ GEM pry (>= 0.9.10, < 0.11.0) pry-rails (0.3.4) pry (>= 0.9.10) - puma (3.2.0) + puma (3.6.0) r2 (0.2.6) rack (1.6.4) rack-mini-profiler (0.10.1) @@ -376,19 +374,19 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - stackprof (0.2.9) - thin (1.6.4) + stackprof (0.2.10) + thin (1.7.0) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) - rack (~> 1.0) + rack (>= 1, < 3) thor (0.19.1) thread_safe (0.3.5) - tilt (2.0.2) - timecop (0.8.0) + tilt (2.0.5) + timecop (0.8.1) trollop (2.1.2) tzinfo (1.2.2) thread_safe (~> 0.1) - uglifier (3.0.0) + uglifier (3.0.2) execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext @@ -396,7 +394,7 @@ GEM unicorn (5.1.0) kgio (~> 2.6) raindrops (~> 0.7) - uniform_notifier (1.9.0) + uniform_notifier (1.10.0) PLATFORMS ruby @@ -503,4 +501,4 @@ DEPENDENCIES unicorn BUNDLED WITH - 1.12.5 + 1.13.4 diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 index 9ff860f745a..57b0b06e1e3 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -1,35 +1,40 @@ import { observes } from 'ember-addons/ember-computed-decorators'; -const REGEXP_FILTER_PREFIXES = /\s?(user:|@|category:|#|group:|badge:|tags?:|in:|status:|posts_count:|(before|after):)/ig; +const REGEXP_BLOCKS = /(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/g; -const REGEXP_USERNAME_PREFIX = /\s?(user:|@)/ig; -const REGEXP_CATEGORY_PREFIX = /\s?(category:|#)/ig; -const REGEXP_GROUP_PREFIX = /\s?group:/ig; -const REGEXP_BADGE_PREFIX = /\s?badge:/ig; -const REGEXP_TAGS_PREFIX = /\s?tags?:/ig; -const REGEXP_IN_PREFIX = /\s?in:/ig; -const REGEXP_STATUS_PREFIX = /\s?status:/ig; -const REGEXP_POST_COUNT_PREFIX = /\s?posts_count:/ig; -const REGEXP_POST_TIME_PREFIX = /\s?(before|after):/ig; +const REGEXP_USERNAME_PREFIX = /(user:|@)/ig; +const REGEXP_CATEGORY_PREFIX = /(category:|#)/ig; +const REGEXP_GROUP_PREFIX = /group:/ig; +const REGEXP_BADGE_PREFIX = /badge:/ig; +const REGEXP_TAGS_PREFIX = /tags?:/ig; +const REGEXP_IN_PREFIX = /in:/ig; +const REGEXP_STATUS_PREFIX = /status:/ig; +const REGEXP_POST_COUNT_PREFIX = /posts_count:/ig; +const REGEXP_POST_TIME_PREFIX = /(before|after):/ig; -const REGEXP_CATEGORY_SLUG = /\s?(\#[a-zA-Z0-9\-:]+)/ig; -const REGEXP_CATEGORY_ID = /\s?(category:[0-9]+)/ig; -const REGEXP_POST_TIME_WHEN = /(before|after)/ig; +const REGEXP_IN_MATCH = /in:(posted|watching|tracking|bookmarks|first|pinned|unpinned)/ig; +const REGEXP_SPECIAL_IN_LIKES_MATCH = /in:likes/ig; +const REGEXP_SPECIAL_IN_PRIVATE_MATCH = /in:private/ig; +const REGEXP_SPECIAL_IN_WIKI_MATCH = /in:wiki/ig; + +const REGEXP_CATEGORY_SLUG = /(\#[a-zA-Z0-9\-:]+)/ig; +const REGEXP_CATEGORY_ID = /(category:[0-9]+)/ig; +const REGEXP_POST_TIME_WHEN = /(before|after)/ig; export default Em.Component.extend({ classNames: ['search-advanced-options'], inOptions: [ - {name: I18n.t('search.advanced.filters.likes'), value: "likes"}, + //{name: I18n.t('search.advanced.filters.likes'), value: "likes"}, {name: I18n.t('search.advanced.filters.posted'), value: "posted"}, {name: I18n.t('search.advanced.filters.watching'), value: "watching"}, {name: I18n.t('search.advanced.filters.tracking'), value: "tracking"}, - {name: I18n.t('search.advanced.filters.private'), value: "private"}, + //{name: I18n.t('search.advanced.filters.private'), value: "private"}, {name: I18n.t('search.advanced.filters.bookmarks'), value: "bookmarks"}, {name: I18n.t('search.advanced.filters.first'), value: "first"}, {name: I18n.t('search.advanced.filters.pinned'), value: "pinned"}, {name: I18n.t('search.advanced.filters.unpinned'), value: "unpinned"}, - {name: I18n.t('search.advanced.filters.wiki'), value: "wiki"} + //{name: I18n.t('search.advanced.filters.wiki'), value: "wiki"} ], statusOptions: [ {name: I18n.t('search.advanced.statuses.open'), value: "open"}, @@ -63,6 +68,13 @@ export default Em.Component.extend({ badge: [], tags: [], in: '', + special: { + in: { + likes: false, + private: false, + wiki: false + } + }, status: '', posts_count: '', time: { @@ -74,107 +86,204 @@ export default Em.Component.extend({ }, _update() { - let searchTerm = this.get('searchTerm'); - - if (!searchTerm) { + if (!this.get('searchTerm')) { this._init(); return; } - this.findUsername(searchTerm); - this.findCategory(searchTerm); - this.findGroup(searchTerm); - this.findBadge(searchTerm); - this.findTags(searchTerm); - this.findIn(searchTerm); - this.findStatus(searchTerm); - this.findPostsCount(searchTerm); - this.findPostTime(searchTerm); + this.findSearchTerms(); + this.setSearchedTermValue('searchedTerms.username', REGEXP_USERNAME_PREFIX); + this.setSearchedTermValueForCategory(); + this.setSearchedTermValueForGroup(); + this.setSearchedTermValueForBadge(); + this.setSearchedTermValueForTags(); + this.setSearchedTermValue('searchedTerms.in', REGEXP_IN_PREFIX, REGEXP_IN_MATCH); + this.setSearchedTermSpecialInValue('searchedTerms.special.in.likes', REGEXP_SPECIAL_IN_LIKES_MATCH); + this.setSearchedTermSpecialInValue('searchedTerms.special.in.private', REGEXP_SPECIAL_IN_PRIVATE_MATCH); + this.setSearchedTermSpecialInValue('searchedTerms.special.in.wiki', REGEXP_SPECIAL_IN_WIKI_MATCH); + this.setSearchedTermValue('searchedTerms.status', REGEXP_STATUS_PREFIX); + this.setSearchedTermValueForPostTime(); + this.setSearchedTermValue('searchedTerms.posts_count', REGEXP_POST_COUNT_PREFIX); }, - findSearchTerm(EXPRESSION, searchTerm) { + findSearchTerms() { + const searchTerm = this.get('searchTerm'); if (!searchTerm) - return ""; + return []; - const expressionPosition = searchTerm.search(EXPRESSION); - if (expressionPosition === -1) - return ""; + let result = []; + const blocks = searchTerm.match(REGEXP_BLOCKS); + _.each(blocks, function(block) { + if (block.length === 0) return; - const remainingPhrases = searchTerm.substring(expressionPosition + 2); - let nextExpressionPosition = remainingPhrases.search(REGEXP_FILTER_PREFIXES); - if (nextExpressionPosition === -1) - nextExpressionPosition = remainingPhrases.length; + result.push(block); + }); - return searchTerm.substring(expressionPosition, nextExpressionPosition + expressionPosition + 2).trim().split(' ')[0]; + this.set('searchTermBlocks', result); }, - findUsername(searchTerm) { - const match = this.findSearchTerm(REGEXP_USERNAME_PREFIX, searchTerm); + filterBlocks(regexPrefix) { + let result = []; + _.each(this.get('searchTermBlocks'), function(block) { + if (block.search(regexPrefix) !== -1) + result.push(block); + }); + + return result; + }, + + setSearchedTermValue(key, replaceRegEx, matchRegEx = null) { + matchRegEx = matchRegEx || replaceRegEx; + const match = this.filterBlocks(matchRegEx); if (match.length !== 0) { - let userInput = match.replace(REGEXP_USERNAME_PREFIX, ''); - - if (userInput.length !== 0 && this.get('searchedTerms.username') !== userInput) { - this.set('searchedTerms.username', userInput); + const userInput = match[0].replace(replaceRegEx, ''); + if (this.get(key) !== userInput) { + this.set(key, userInput); } - } else { - this.set('searchedTerms.username', null); + } else if(this.get(key) !== null) { + this.set(key, null); } }, - @observes('searchedTerms.username') - updateUsername() { - let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_USERNAME_PREFIX, searchTerm); - const userFilter = this.get('searchedTerms.username'); + setSearchedTermSpecialInValue(key, replaceRegEx, matchRegEx = null) { + matchRegEx = matchRegEx || replaceRegEx; + const match = this.filterBlocks(matchRegEx); - if (userFilter && userFilter.length !== 0) { - if (match.length !== 0) { - searchTerm = searchTerm.replace(match, `@${userFilter}`); - } else { - searchTerm += ` @${userFilter}`; + if (match.length !== 0) { + if (this.get(key) !== true) { + this.set(key, true); } - } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); + } else if(this.get(key) !== false) { + this.set(key, false); } - - this.set('searchTerm', searchTerm); }, - findCategory(searchTerm) { - const match = this.findSearchTerm(REGEXP_CATEGORY_PREFIX, searchTerm); + setSearchedTermValueForCategory() { + const match = this.filterBlocks(REGEXP_CATEGORY_PREFIX); if (match.length !== 0) { - let existingInput = _.isArray(this.get('searchedTerms.category')) ? this.get('searchedTerms.category')[0] : this.get('searchedTerms.category'); - const subcategories = match.replace(REGEXP_CATEGORY_PREFIX, '').split(':'); + const existingInput = this.get('searchedTerms.category'); + const subcategories = match[0].replace(REGEXP_CATEGORY_PREFIX, '').split(':'); if (subcategories.length > 1) { - let userInput = Discourse.Category.findBySlug(subcategories[1], subcategories[0]); + const userInput = Discourse.Category.findBySlug(subcategories[1], subcategories[0]); if ((!existingInput && userInput) || (existingInput && userInput && existingInput.id !== userInput.id)) this.set('searchedTerms.category', userInput.id); } else - if (isNaN(subcategories)) { - let userInput = Discourse.Category.findSingleBySlug(subcategories[0]); - if ((!existingInput && userInput) - || (existingInput && userInput && existingInput.id !== userInput.id)) - this.set('searchedTerms.category', userInput.id); - } else { - let userInput = Discourse.Category.findById(subcategories[0]); - if ((!existingInput && userInput) - || (existingInput && userInput && existingInput.id !== userInput.id)) - this.set('searchedTerms.category', userInput.id); - } + if (isNaN(subcategories)) { + const userInput = Discourse.Category.findSingleBySlug(subcategories[0]); + if ((!existingInput && userInput) + || (existingInput && userInput && existingInput.id !== userInput.id)) + this.set('searchedTerms.category', userInput.id); + } else { + const userInput = Discourse.Category.findById(subcategories[0]); + if ((!existingInput && userInput) + || (existingInput && userInput && existingInput.id !== userInput.id)) + this.set('searchedTerms.category', userInput.id); + } } else this.set('searchedTerms.category', null); }, - @observes('searchedTerms.category') - updateCategory() { - let searchTerm = this.get('searchTerm'); - const categoryFilter = Discourse.Category.findById(this.get('searchedTerms.category')); + setSearchedTermValueForGroup() { + const match = this.filterBlocks(REGEXP_GROUP_PREFIX); + const group = this.get('searchedTerms.group'); - const match = this.findSearchTerm(REGEXP_CATEGORY_PREFIX, searchTerm); - const slugCategoryMatches = match.match(REGEXP_CATEGORY_SLUG); - const idCategoryMatches = match.match(REGEXP_CATEGORY_ID); + if (match.length !== 0) { + const existingInput = _.isArray(group) ? group[0] : group; + const userInput = match[0].replace(REGEXP_GROUP_PREFIX, ''); + + if (existingInput !== userInput) { + this.set('searchedTerms.group', (userInput.length !== 0) ? [userInput] : []); + } + } else if (group.length !== 0) { + this.set('searchedTerms.group', []); + } + }, + + setSearchedTermValueForBadge() { + const match = this.filterBlocks(REGEXP_BADGE_PREFIX); + const badge = this.get('searchedTerms.badge'); + + if (match.length !== 0) { + const existingInput = _.isArray(badge) ? badge[0] : badge; + const userInput = match[0].replace(REGEXP_BADGE_PREFIX, ''); + + if (existingInput !== userInput) { + this.set('searchedTerms.badge', (userInput.length !== 0) ? [userInput] : []); + } + } else if (badge.length !== 0) { + this.set('searchedTerms.badge', []); + } + }, + + setSearchedTermValueForTags() { + if (!this.siteSettings.tagging_enabled) return; + + const match = this.filterBlocks(REGEXP_TAGS_PREFIX); + const tags = this.get('searchedTerms.tags'); + + if (match.length !== 0) { + const existingInput = _.isArray(tags) ? tags.join(',') : tags; + const userInput = match[0].replace(REGEXP_TAGS_PREFIX, ''); + + if (existingInput !== userInput) { + this.set('searchedTerms.tags', (userInput.length !== 0) ? userInput.split(',') : []); + } + } else if (tags.length !== 0) { + this.set('searchedTerms.tags', []); + } + }, + + setSearchedTermValueForPostTime() { + const match = this.filterBlocks(REGEXP_POST_TIME_PREFIX); + + if (match.length !== 0) { + const existingInputWhen = this.get('searchedTerms.time.when'); + const userInputWhen = match[0].match(REGEXP_POST_TIME_WHEN)[0]; + const existingInputDays = this.get('searchedTerms.time.days'); + const userInputDays = match[0].replace(REGEXP_POST_TIME_PREFIX, ''); + + if (existingInputWhen !== userInputWhen) { + this.set('searchedTerms.time.when', userInputWhen); + } + + if (existingInputDays !== userInputDays) { + this.set('searchedTerms.time.days', userInputDays); + } + } else { + this.set('searchedTerms.time.days', ''); + } + }, + + @observes('searchedTerms.username') + updateSearchTermForUsername() { + const match = this.filterBlocks(REGEXP_USERNAME_PREFIX); + const userFilter = this.get('searchedTerms.username'); + let searchTerm = this.get('searchTerm'); + + if (userFilter && userFilter.length !== 0) { + if (match.length !== 0) { + searchTerm = searchTerm.replace(match[0], `@${userFilter}`); + } else { + searchTerm += ` @${userFilter}`; + } + + this.set('searchTerm', searchTerm.trim()); + } else if (match.length !== 0) { + searchTerm = searchTerm.replace(match[0], ''); + this.set('searchTerm', searchTerm.trim()); + } + }, + + @observes('searchedTerms.category') + updateSearchTermForCategory() { + const match = this.filterBlocks(REGEXP_CATEGORY_PREFIX); + const categoryFilter = Discourse.Category.findById(this.get('searchedTerms.category')); + let searchTerm = this.get('searchTerm'); + + const slugCategoryMatches = (match.length !== 0) ? match[0].match(REGEXP_CATEGORY_SLUG) : null; + const idCategoryMatches = (match.length !== 0) ? match[0].match(REGEXP_CATEGORY_ID) : null; if (categoryFilter && categoryFilter.length !== 0) { const id = categoryFilter.id; const slug = categoryFilter.slug; @@ -186,6 +295,8 @@ export default Em.Component.extend({ searchTerm = searchTerm.replace(idCategoryMatches[0], `category:${id}`); else searchTerm += ` #${parentSlug}:${slug}`; + + this.set('searchTerm', searchTerm.trim()); } else if (categoryFilter) { if (slugCategoryMatches) searchTerm = searchTerm.replace(slugCategoryMatches[0], `#${slug}`); @@ -193,264 +304,211 @@ export default Em.Component.extend({ searchTerm = searchTerm.replace(idCategoryMatches[0], `category:${id}`); else searchTerm += ` #${slug}`; + + this.set('searchTerm', searchTerm.trim()); } } else { if (slugCategoryMatches) searchTerm = searchTerm.replace(slugCategoryMatches[0], ''); if (idCategoryMatches) searchTerm = searchTerm.replace(idCategoryMatches[0], ''); - } - this.set('searchTerm', searchTerm); - }, - - findGroup(searchTerm) { - const match = this.findSearchTerm(REGEXP_GROUP_PREFIX, searchTerm); - const group = this.get('searchedTerms.group'); - - if (match.length !== 0) { - let existingInput = _.isArray(group) ? group[0] : group; - let userInput = match.replace(REGEXP_GROUP_PREFIX, ''); - - if (userInput.length !== 0 && existingInput !== userInput) { - this.set('searchedTerms.group', [userInput]); - } - } else if (group.length !== 0) { - this.set('searchedTerms.group', []); + this.set('searchTerm', searchTerm.trim()); } }, @observes('searchedTerms.group') - updateGroup() { - let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_GROUP_PREFIX, searchTerm); + updateSearchTermForGroup() { + const match = this.filterBlocks(REGEXP_GROUP_PREFIX); const groupFilter = this.get('searchedTerms.group'); + let searchTerm = this.get('searchTerm'); if (groupFilter && groupFilter.length !== 0) { if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ` group:${groupFilter}`); + searchTerm = searchTerm.replace(match[0], ` group:${groupFilter}`); } else { searchTerm += ` group:${groupFilter}`; } this.set('searchTerm', searchTerm); } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm); - } - }, - - findBadge(searchTerm) { - const match = this.findSearchTerm(REGEXP_BADGE_PREFIX, searchTerm); - const badge = this.get('searchedTerms.badge'); - - if (match.length !== 0) { - let existingInput = _.isArray(badge) ? badge[0] : badge; - let userInput = match.replace(REGEXP_BADGE_PREFIX, ''); - - if (userInput.length !== 0 && existingInput !== userInput) { - this.set('searchedTerms.badge', [match.replace(REGEXP_BADGE_PREFIX, '')]); - } - } else if (badge.length !== 0) { - this.set('searchedTerms.badge', []); + searchTerm = searchTerm.replace(match[0], ''); + this.set('searchTerm', searchTerm.trim()); } }, @observes('searchedTerms.badge') - updateBadge() { + updateSearchTermForBadge() { let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_BADGE_PREFIX, searchTerm); + const match = this.filterBlocks(REGEXP_BADGE_PREFIX); const badgeFilter = this.get('searchedTerms.badge'); if (badgeFilter && badgeFilter.length !== 0) { if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ` badge:${badgeFilter}`); + searchTerm = searchTerm.replace(match[0], ` badge:${badgeFilter}`); } else { searchTerm += ` badge:${badgeFilter}`; } this.set('searchTerm', searchTerm); } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm); - } - }, - - findTags(searchTerm) { - if (!this.siteSettings.tagging_enabled) return; - - const match = this.findSearchTerm(REGEXP_TAGS_PREFIX, searchTerm); - const tags = this.get('searchedTerms.tags'); - - if (match.length !== 0) { - let existingInput = _.isArray(tags) ? tags.join(',') : tags; - let userInput = match.replace(REGEXP_TAGS_PREFIX, ''); - - if (userInput.length !== 0 && existingInput !== userInput) { - this.set('searchedTerms.tags', userInput.split(',')); - } - } else if (tags.length !== 0) { - this.set('searchedTerms.tags', []); + searchTerm = searchTerm.replace(match[0], ''); + this.set('searchTerm', searchTerm.trim()); } }, @observes('searchedTerms.tags') - updateTags() { - let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_TAGS_PREFIX, searchTerm); + updateSearchTermForTags() { + const match = this.filterBlocks(REGEXP_TAGS_PREFIX); const tagFilter = this.get('searchedTerms.tags'); + let searchTerm = this.get('searchTerm'); if (tagFilter && tagFilter.length !== 0) { const tags = tagFilter.join(','); if (match.length !== 0) { - searchTerm = searchTerm.replace(match, `tags:${tags}`); + searchTerm = searchTerm.replace(match[0], `tags:${tags}`); } else { searchTerm += ` tags:${tags}`; } - this.set('searchTerm', searchTerm); + this.set('searchTerm', searchTerm.trim()); } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm); + searchTerm = searchTerm.replace(match[0], ''); + this.set('searchTerm', searchTerm.trim()); } }, - findIn(searchTerm) { - const match = this.findSearchTerm(REGEXP_IN_PREFIX, searchTerm); - if (match.length !== 0) { - let existingInput = this.get('searchedTerms.in'); - let userInput = match.replace(REGEXP_IN_PREFIX, ''); - if (userInput.length !== 0 && existingInput !== userInput) - this.set('searchedTerms.in', userInput); - } else - this.set('searchedTerms.in', ''); - }, - @observes('searchedTerms.in') - updateIn() { - let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_IN_PREFIX, searchTerm); + updateSearchTermForIn() { + const match = this.filterBlocks(REGEXP_IN_MATCH); const inFilter = this.get('searchedTerms.in'); + let searchTerm = this.get('searchTerm'); if (inFilter) { if (match.length !== 0) { - searchTerm = searchTerm.replace(match, `in:${inFilter}`); + searchTerm = searchTerm.replace(match[0], `in:${inFilter}`); } else { searchTerm += ` in:${inFilter}`; } - this.set('searchTerm', searchTerm); + this.set('searchTerm', searchTerm.trim()); } else if (match.length !== 0) { searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm); + this.set('searchTerm', searchTerm.trim()); } }, - findStatus(searchTerm) { - const match = this.findSearchTerm(REGEXP_STATUS_PREFIX, searchTerm); - if (match.length !== 0) { - let existingInput = this.get('searchedTerms.status'); - let userInput = match.replace(REGEXP_STATUS_PREFIX, ''); - if (userInput.length !== 0 && existingInput !== userInput) - this.set('searchedTerms.status', userInput); - } else - this.set('searchedTerms.status', ''); + @observes('searchedTerms.special.in.likes') + updateSearchTermForSpecialInLikes() { + const match = this.filterBlocks(REGEXP_SPECIAL_IN_LIKES_MATCH); + const inFilter = this.get('searchedTerms.special.in.likes'); + let searchTerm = this.get('searchTerm'); + + if (inFilter) { + if (match.length === 0) { + searchTerm += ` in:likes`; + this.set('searchTerm', searchTerm.trim()); + } + } else if (match.length !== 0) { + searchTerm = searchTerm.replace(match, ''); + this.set('searchTerm', searchTerm.trim()); + } + }, + + @observes('searchedTerms.special.in.private') + updateSearchTermForSpecialInPrivate() { + const match = this.filterBlocks(REGEXP_SPECIAL_IN_PRIVATE_MATCH); + const inFilter = this.get('searchedTerms.special.in.private'); + let searchTerm = this.get('searchTerm'); + + if (inFilter) { + if (match.length === 0) { + searchTerm += ` in:private`; + this.set('searchTerm', searchTerm.trim()); + } + } else if (match.length !== 0) { + searchTerm = searchTerm.replace(match, ''); + this.set('searchTerm', searchTerm.trim()); + } + }, + + @observes('searchedTerms.special.in.wiki') + updateSearchTermForSpecialInWiki() { + const match = this.filterBlocks(REGEXP_SPECIAL_IN_WIKI_MATCH); + const inFilter = this.get('searchedTerms.special.in.wiki'); + let searchTerm = this.get('searchTerm'); + + if (inFilter) { + if (match.length === 0) { + searchTerm += ` in:wiki`; + this.set('searchTerm', searchTerm.trim()); + } + } else if (match.length !== 0) { + searchTerm = searchTerm.replace(match, ''); + this.set('searchTerm', searchTerm.trim()); + } }, @observes('searchedTerms.status') - updateStatus() { - let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_STATUS_PREFIX, searchTerm); + updateSearchTermForStatus() { + const match = this.filterBlocks(REGEXP_STATUS_PREFIX); const statusFilter = this.get('searchedTerms.status'); + let searchTerm = this.get('searchTerm'); if (statusFilter) { if (match.length !== 0) { - searchTerm = searchTerm.replace(match, `status:${statusFilter}`); + searchTerm = searchTerm.replace(match[0], `status:${statusFilter}`); } else { searchTerm += ` status:${statusFilter}`; } - this.set('searchTerm', searchTerm); + this.set('searchTerm', searchTerm.trim()); } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm); - } - }, - - findPostsCount(searchTerm) { - const match = this.findSearchTerm(REGEXP_POST_COUNT_PREFIX, searchTerm); - if (match.length !== 0) { - let existingInput = this.get('searchedTerms.posts_count'); - let userInput = match.replace(REGEXP_POST_COUNT_PREFIX, ''); - if (userInput.length !== 0 && existingInput !== userInput) - this.set('searchedTerms.posts_count', userInput); - } else - this.set('searchedTerms.posts_count', ''); - }, - - @observes('searchedTerms.posts_count') - updatePostsCount() { - let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_POST_COUNT_PREFIX, searchTerm); - const postsCountFilter = this.get('searchedTerms.posts_count'); - - if (postsCountFilter) { - if (match.length !== 0) { - searchTerm = searchTerm.replace(match, `posts_count:${postsCountFilter}`); - } else { - searchTerm += ` posts_count:${postsCountFilter}`; - } - - this.set('searchTerm', searchTerm); - } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm); - } - }, - - findPostTime(searchTerm) { - const match = this.findSearchTerm(REGEXP_POST_TIME_WHEN, searchTerm); - - if (match.length !== 0) { - let existingInputWhen = this.get('searchedTerms.time.when'); - let userInputWhen = match.match(REGEXP_POST_TIME_WHEN)[0]; - let existingInputDays = this.get('searchedTerms.time.days'); - let userInputDays = match.replace(REGEXP_POST_TIME_PREFIX, ''); - - if (userInputWhen.length !== 0 && existingInputWhen !== userInputWhen) { - this.set('searchedTerms.time.when', userInputWhen); - } - - if (userInputDays.length !== 0 && - existingInputDays !== userInputDays && - userInputDays !== match) { - this.set('searchedTerms.time.days', userInputDays); - } - } else { - this.set('searchedTerms.time.days', ''); + searchTerm = searchTerm.replace(match[0], ''); + this.set('searchTerm', searchTerm.trim()); } }, @observes('searchedTerms.time.when', 'searchedTerms.time.days') - updatePostTime() { - let searchTerm = this.get('searchTerm'); - const match = this.findSearchTerm(REGEXP_POST_TIME_PREFIX, searchTerm); + updateSearchTermForPostTime() { + const match = this.filterBlocks(REGEXP_POST_TIME_PREFIX); const timeDaysFilter = this.get('searchedTerms.time.days'); + let searchTerm = this.get('searchTerm'); if (timeDaysFilter) { const when = this.get('searchedTerms.time.when'); if (match.length !== 0) { - searchTerm = searchTerm.replace(match, `${when}:${timeDaysFilter}`); + searchTerm = searchTerm.replace(match[0], `${when}:${timeDaysFilter}`); } else { searchTerm += ` ${when}:${timeDaysFilter}`; } - this.set('searchTerm', searchTerm); + this.set('searchTerm', searchTerm.trim()); } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm); + searchTerm = searchTerm.replace(match[0], ''); + this.set('searchTerm', searchTerm.trim()); + } + }, + + @observes('searchedTerms.posts_count') + updateSearchTermForPostsCount() { + const match = this.filterBlocks(REGEXP_POST_COUNT_PREFIX); + const postsCountFilter = this.get('searchedTerms.posts_count'); + let searchTerm = this.get('searchTerm'); + + if (postsCountFilter) { + if (match.length !== 0) { + searchTerm = searchTerm.replace(match[0], `posts_count:${postsCountFilter}`); + } else { + searchTerm += ` posts_count:${postsCountFilter}`; + } + + this.set('searchTerm', searchTerm.trim()); + } else if (match.length !== 0) { + searchTerm = searchTerm.replace(match[0], ''); + this.set('searchTerm', searchTerm.trim()); } }, diff --git a/app/assets/javascripts/discourse/components/tag-chooser.js.es6 b/app/assets/javascripts/discourse/components/tag-chooser.js.es6 index a545c5126f1..a8fa6bc1835 100644 --- a/app/assets/javascripts/discourse/components/tag-chooser.js.es6 +++ b/app/assets/javascripts/discourse/components/tag-chooser.js.es6 @@ -56,6 +56,7 @@ export default Ember.TextField.extend({ placeholder: this.get('placeholder') === "" ? "" : I18n.t(this.get('placeholderKey') || 'tagging.choose_for_topic'), maximumInputLength: this.siteSettings.max_tag_length, maximumSelectionSize: limit, + width: this.get('width') || 'resolve', initSelection(element, callback) { const data = []; diff --git a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 index 476ff985077..75e60da098e 100644 --- a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 +++ b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 @@ -11,8 +11,8 @@ function renderAvatar(user, options) { if (!username || !avatarTemplate) { return ''; } - let title; - if (!options.ignoreTitle) { + let title = options.title; + if (!title && !options.ignoreTitle) { // first try to get a title title = Em.get(user, 'title'); // if there was no title provided diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 002f998c359..8341e4cf57f 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -132,7 +132,7 @@ export default function() { this.route('showCategory' + filter.capitalize(), {path: '/c/:category/:tag_id/l/' + filter}); this.route('showParentCategory' + filter.capitalize(), {path: '/c/:parent_category/:category/:tag_id/l/' + filter}); }); - this.route('show', {path: 'intersection/:tag_id/*additional_tags'}); + this.route('intersection', {path: 'intersection/:tag_id/*additional_tags'}); }); this.resource('tagGroups', {path: '/tag_groups'}, function() { diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs index e3cde3711d9..554a2d689cf 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -40,7 +40,7 @@
- {{tag-chooser tags=searchedTerms.tags blacklist=searchedTerms.tags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true"}} + {{tag-chooser tags=searchedTerms.tags blacklist=searchedTerms.tags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true" width="70%"}}
@@ -50,6 +50,11 @@
+
+ + + +
{{combo-box id="in" valueAttribute="value" content=inOptions value=searchedTerms.in none="user.locale.any"}}
@@ -66,7 +71,7 @@
{{combo-box id="postTime" valueAttribute="value" content=postTimeOptions value=searchedTerms.time.when}} - {{input type="text" value=searchedTerms.time.days class="input-small" id='search-post-date'}} + {{input type="date" value=searchedTerms.time.days class="input-small" id='search-post-date'}}
diff --git a/app/assets/javascripts/discourse/templates/user/preferences.hbs b/app/assets/javascripts/discourse/templates/user/preferences.hbs index 0f2597edbb0..171da9306b9 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/user/preferences.hbs @@ -336,7 +336,13 @@ {{else}} {{d-button action="revokeApiKey" actionParam=key class="btn" label="user.revoke_access"}} {{/if}} -

{{i18n "user.api_permissions"}} {{#if key.write}}{{i18n "user.api_read_write"}}{{else}}{{i18n "user.api_read"}}{{/if}}

+

+

+

{{i18n "user.api_approved"}} {{bound-date key.created_at}}

{{/each}} diff --git a/app/assets/stylesheets/common/base/search.scss b/app/assets/stylesheets/common/base/search.scss index ba279d4718b..7fa038db9c1 100644 --- a/app/assets/stylesheets/common/base/search.scss +++ b/app/assets/stylesheets/common/base/search.scss @@ -114,6 +114,10 @@ .control-group.pull-left { width: 50%; } + + input[type="checkbox"] { + height: auto; + } } } diff --git a/app/assets/stylesheets/mobile/search.scss b/app/assets/stylesheets/mobile/search.scss index 1dbce919d8a..bb4ea638c3f 100644 --- a/app/assets/stylesheets/mobile/search.scss +++ b/app/assets/stylesheets/mobile/search.scss @@ -13,7 +13,12 @@ } .controls { - input,select { + label { + display: block; + margin-bottom: 5px; + } + + input:not([type="checkbox"]),select { width: 75%; } } diff --git a/app/controllers/user_api_keys_controller.rb b/app/controllers/user_api_keys_controller.rb index 20ba2d84aa5..8d11d46c1e7 100644 --- a/app/controllers/user_api_keys_controller.rb +++ b/app/controllers/user_api_keys_controller.rb @@ -6,7 +6,7 @@ class UserApiKeysController < ApplicationController skip_before_filter :check_xhr, :preload_json before_filter :ensure_logged_in, only: [:create, :revoke, :undo_revoke] - AUTH_API_VERSION ||= 1 + AUTH_API_VERSION ||= 2 def new @@ -34,14 +34,14 @@ class UserApiKeysController < ApplicationController return end - @access_description = params[:access].include?("w") ? t("user_api_key.read_write") : t("user_api_key.read") @application_name = params[:application_name] @public_key = params[:public_key] @nonce = params[:nonce] - @access = params[:access] @client_id = params[:client_id] @auth_redirect = params[:auth_redirect] @push_url = params[:push_url] + @localized_scopes = params[:scopes].split(",").map{|s| I18n.t("user_api_key.scopes.#{s}")} + @scopes = params[:scopes] rescue Discourse::InvalidAccess @generic_error = true @@ -60,10 +60,6 @@ class UserApiKeysController < ApplicationController raise Discourse::InvalidAccess unless meets_tl? - request_read = params[:access].include? 'r' - request_read ||= params[:access].include? 'p' - request_write = params[:access].include? 'w' - validate_params # destroy any old keys we had @@ -72,12 +68,10 @@ class UserApiKeysController < ApplicationController key = UserApiKey.create!( application_name: params[:application_name], client_id: params[:client_id], - read: request_read, - push: params[:push_url].present?, user_id: current_user.id, - write: request_write, + push_url: params[:push_url], key: SecureRandom.hex, - push_url: params[:push_url] + scopes: params[:scopes].split(",") ) # we keep the payload short so it encrypts easily with public key @@ -85,7 +79,8 @@ class UserApiKeysController < ApplicationController payload = { key: key.key, nonce: params[:nonce], - access: key.access + push: key.has_push?, + api: AUTH_API_VERSION }.to_json public_key = OpenSSL::PKey::RSA.new(params[:public_key]) @@ -100,7 +95,7 @@ class UserApiKeysController < ApplicationController if current_key = request.env['HTTP_USER_API_KEY'] request_key = UserApiKey.find_by(key: current_key) revoke_key ||= request_key - if request_key && request_key.id != revoke_key.id && !request_key.write + if request_key && request_key.id != revoke_key.id && !request_key.scopes.include?("write") raise Discourse::InvalidAccess end end @@ -127,7 +122,7 @@ class UserApiKeysController < ApplicationController [ :public_key, :nonce, - :access, + :scopes, :client_id, :auth_redirect, :application_name @@ -135,13 +130,9 @@ class UserApiKeysController < ApplicationController end def validate_params - request_read = params[:access].include? 'r' - request_read ||= params[:access].include? 'p' - request_write = params[:access].include? 'w' + requested_scopes = Set.new(params[:scopes].split(",")) - raise Discourse::InvalidAccess unless request_read || request_push - raise Discourse::InvalidAccess if request_read && !SiteSetting.allow_read_user_api_keys - raise Discourse::InvalidAccess if request_write && !SiteSetting.allow_write_user_api_keys + raise Discourse::InvalidAccess unless UserApiKey.allowed_scopes.superset?(requested_scopes) # our pk has got to parse OpenSSL::PKey::RSA.new(params[:public_key]) diff --git a/app/models/user_api_key.rb b/app/models/user_api_key.rb index b8709328f76..6335fa5bdc4 100644 --- a/app/models/user_api_key.rb +++ b/app/models/user_api_key.rb @@ -1,10 +1,63 @@ class UserApiKey < ActiveRecord::Base + + SCOPES = { + read: [:get], + write: [:get, :post, :patch], + message_bus: [[:post, 'message_bus']], + push: nil, + notifications: [[:post, 'message_bus'], [:get, 'notifications#index'], [:put, 'notifications#mark_read']], + session_info: [[:get, 'session#current'], [:get, 'users#topic_tracking_state']] + } + belongs_to :user - def access - has_push = push && push_url.present? && SiteSetting.allowed_user_api_push_urls.include?(push_url) - "#{read ? "r" : ""}#{write ? "w" : ""}#{has_push ? "p" : ""}" + def self.allowed_scopes + Set.new(SiteSetting.allow_user_api_key_scopes.split("|")) end + + def self.available_scopes + @available_scopes ||= Set.new(SCOPES.keys.map(&:to_s)) + end + + def self.allow_permission?(permission, env) + verb, action = permission + actual_verb = env["REQUEST_METHOD"] || "" + + # safe in Ruby 2.3 which is only one supported + return false unless actual_verb.downcase == verb.to_s + return true unless action + + # not a rails route, special handling + return true if action == "message_bus" && env["PATH_INFO"] =~ /^\/message-bus\/.*\/poll/ + + params = env['action_dispatch.request.path_parameters'] + + return false unless params + + actual_action = "#{params[:controller]}##{params[:action]}" + actual_action == action + end + + def self.allow_scope?(name, env) + if allowed = SCOPES[name.to_sym] + good = allowed.any? do |permission| + allow_permission?(permission, env) + end + + good || allow_permission?([:post, 'user_api_keys#revoke'], env) + end + end + + def has_push? + (scopes.include?("push") || scopes.include?("notifications")) && push_url.present? && SiteSetting.allowed_user_api_push_urls.include?(push_url) + end + + def allow?(env) + scopes.any? do |name| + UserApiKey.allow_scope?(name, env) + end + end + end # == Schema Information diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 40163f2450b..4017061a46a 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -148,8 +148,7 @@ class UserSerializer < BasicUserSerializer { id: k.id, application_name: k.application_name, - read: k.read, - write: k.write, + scopes: k.scopes.map{|s| I18n.t("user_api_key.scopes.#{s}")}, created_at: k.created_at } end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 83a06aeaa12..00e27ab3727 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -396,9 +396,9 @@ class PostAlerter end def push_notification(user, payload) - if SiteSetting.allow_push_user_api_keys && SiteSetting.allowed_user_api_push_urls.present? + if SiteSetting.allow_user_api_key_scopes.split("|").include?("push") && SiteSetting.allowed_user_api_push_urls.present? clients = user.user_api_keys - .where('push AND push_url IS NOT NULL AND position(push_url in ?) > 0 AND revoked_at IS NULL', + .where("('push' = ANY(scopes) OR 'notifications' = ANY(scopes)) AND push_url IS NOT NULL AND position(push_url in ?) > 0 AND revoked_at IS NULL", SiteSetting.allowed_user_api_push_urls) .pluck(:client_id, :push_url) diff --git a/app/views/user_api_keys/new.html.erb b/app/views/user_api_keys/new.html.erb index 6e0a6892091..fb689adc864 100644 --- a/app/views/user_api_keys/new.html.erb +++ b/app/views/user_api_keys/new.html.erb @@ -1,5 +1,5 @@

<%= t "user_api_key.title" %>

-
+
<% if @no_trust_level %>

<%= t("user_api_key.no_trust_level") %> @@ -10,7 +10,14 @@

<% else %>

- <%= t("user_api_key.description", application_name: @application_name, access: @access_description) %> + <%= t("user_api_key.description", application_name: @application_name) %> +

+

+

    + <%- @localized_scopes.each do |scope| %> +
  • <%= scope %>
  • + <%- end %> +

<%= form_tag(user_api_key_path) do %> <%= hidden_field_tag 'application_name', @application_name %> @@ -20,6 +27,7 @@ <%= hidden_field_tag 'auth_redirect', @auth_redirect %> <%= hidden_field_tag 'push_url', @push_url %> <%= hidden_field_tag 'public_key', @public_key%> + <%= hidden_field_tag 'scopes', @scopes%> <%= submit_tag t('user_api_key.authorize'), class: 'btn btn-danger', id: 'submit' %> <% end %>