Feature #44167 » oauth2-client-metadata-import.patch
| w/app/javascript/controllers/oauth_metadata_import_controller.js | ||
|---|---|---|
| 1 |
import { Controller } from "@hotwired/stimulus";
|
|
| 2 | ||
| 3 |
export default class extends Controller {
|
|
| 4 |
static targets = ["source", "name", "redirectUri", "message"]; |
|
| 5 |
static values = {
|
|
| 6 |
invalidMessage: String, |
|
| 7 |
unknownScopesMessage: String |
|
| 8 |
}; |
|
| 9 | ||
| 10 |
openFilePicker(event) {
|
|
| 11 |
event.preventDefault(); |
|
| 12 |
this.sourceTarget.click(); |
|
| 13 |
} |
|
| 14 | ||
| 15 |
async apply() {
|
|
| 16 |
const file = this.sourceTarget.files[0]; |
|
| 17 |
if (!file) return; |
|
| 18 | ||
| 19 |
let metadata; |
|
| 20 |
try {
|
|
| 21 |
metadata = JSON.parse(await file.text()); |
|
| 22 |
} catch {
|
|
| 23 |
metadata = null; |
|
| 24 |
} |
|
| 25 |
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
| 26 |
this.showMessage(this.invalidMessageValue, true); |
|
| 27 |
return; |
|
| 28 |
} |
|
| 29 | ||
| 30 |
if (typeof metadata.client_name === "string") {
|
|
| 31 |
this.nameTarget.value = metadata.client_name; |
|
| 32 |
} |
|
| 33 |
if (Array.isArray(metadata.redirect_uris)) {
|
|
| 34 |
this.redirectUriTarget.value = metadata.redirect_uris.join("\n");
|
|
| 35 |
} |
|
| 36 | ||
| 37 |
const unknownScopes = this.applyScopes(metadata.scope); |
|
| 38 |
if (unknownScopes.length > 0) {
|
|
| 39 |
this.showMessage(`${this.unknownScopesMessageValue} ${unknownScopes.join(", ")}`, true);
|
|
| 40 |
} else {
|
|
| 41 |
this.clearMessage(); |
|
| 42 |
} |
|
| 43 |
} |
|
| 44 | ||
| 45 |
applyScopes(scope) {
|
|
| 46 |
if (typeof scope !== "string") return []; |
|
| 47 | ||
| 48 |
const requestedScopes = scope.split(/\s+/).filter((s) => s.length > 0); |
|
| 49 |
const knownScopes = new Set(); |
|
| 50 |
const checkboxes = this.element.querySelectorAll('input[type="checkbox"][name="doorkeeper_application[scopes][]"]');
|
|
| 51 |
checkboxes.forEach((checkbox) => {
|
|
| 52 |
knownScopes.add(checkbox.value); |
|
| 53 |
// disabled checkboxes are public permissions that are always granted |
|
| 54 |
if (checkbox.disabled) return; |
|
| 55 | ||
| 56 |
checkbox.checked = requestedScopes.includes(checkbox.value); |
|
| 57 |
}); |
|
| 58 |
return requestedScopes.filter((s) => !knownScopes.has(s)); |
|
| 59 |
} |
|
| 60 | ||
| 61 |
showMessage(text, isError) {
|
|
| 62 |
this.messageTarget.textContent = text; |
|
| 63 |
this.messageTarget.className = isError ? "flash error" : "flash notice"; |
|
| 64 |
} |
|
| 65 | ||
| 66 |
clearMessage() {
|
|
| 67 |
this.messageTarget.textContent = ""; |
|
| 68 |
this.messageTarget.removeAttribute("class");
|
|
| 69 |
} |
|
| 70 |
} |
|
| w/app/views/doorkeeper/applications/_form.html.erb | ||
|---|---|---|
| 1 | 1 |
<%= error_messages_for 'application' %> |
| 2 | 2 |
<div class="box tabular"> |
| 3 |
<p><%= f.text_field :name, :required => true %></p> |
|
| 3 |
<p><%= f.text_field :name, :required => true, :data => {'oauth-metadata-import-target' => 'name'} %></p>
|
|
| 4 | 4 | |
| 5 | 5 |
<p> |
| 6 |
<%= f.textarea :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri' %> |
|
| 6 |
<%= f.textarea :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri', |
|
| 7 |
:data => {'oauth-metadata-import-target' => 'redirectUri'} %>
|
|
| 7 | 8 |
<em class="info"> |
| 8 | 9 |
<%= t('doorkeeper.applications.help.redirect_uri') %>
|
| 9 | 10 |
</em> |
| w/app/views/doorkeeper/applications/edit.html.erb | ||
|---|---|---|
| 1 |
<div data-controller="oauth-metadata-import" |
|
| 2 |
data-oauth-metadata-import-invalid-message-value="<%= l(:error_can_not_read_import_file) %>" |
|
| 3 |
data-oauth-metadata-import-unknown-scopes-message-value="<%= l(:text_oauth_import_client_metadata_unknown_scopes) %>"> |
|
| 4 |
<div class="contextual"> |
|
| 5 |
<%= link_to sprite_icon('import', l(:button_import)), '#',
|
|
| 6 |
:class => 'icon icon-import', |
|
| 7 |
:data => {:action => 'oauth-metadata-import#openFilePicker'} %>
|
|
| 8 |
</div> |
|
| 1 | 9 |
<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
|
| 10 |
<%= file_field_tag 'oauth_metadata_file', :class => 'hidden', |
|
| 11 |
:accept => '.json,application/json', |
|
| 12 |
:data => {'oauth-metadata-import-target' => 'source', :action => 'oauth-metadata-import#apply'} %>
|
|
| 13 |
<p data-oauth-metadata-import-target="message"></p> |
|
| 2 | 14 | |
| 3 | 15 |
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %> |
| 4 | 16 |
<%= render :partial => 'form', :locals => {:f => f} %>
|
| 5 | 17 |
<%= submit_tag l(:button_save) %> |
| 6 | 18 |
<% end %> |
| 19 |
</div> |
|
| w/app/views/doorkeeper/applications/new.html.erb | ||
|---|---|---|
| 1 |
<div data-controller="oauth-metadata-import" |
|
| 2 |
data-oauth-metadata-import-invalid-message-value="<%= l(:error_can_not_read_import_file) %>" |
|
| 3 |
data-oauth-metadata-import-unknown-scopes-message-value="<%= l(:text_oauth_import_client_metadata_unknown_scopes) %>"> |
|
| 4 |
<div class="contextual"> |
|
| 5 |
<%= link_to sprite_icon('import', l(:button_import)), '#',
|
|
| 6 |
:class => 'icon icon-import', |
|
| 7 |
:data => {:action => 'oauth-metadata-import#openFilePicker'} %>
|
|
| 8 |
</div> |
|
| 1 | 9 |
<%= title [l('label_oauth_application_plural'), oauth_applications_path], t('.title') %>
|
| 10 |
<%= file_field_tag 'oauth_metadata_file', :class => 'hidden', |
|
| 11 |
:accept => '.json,application/json', |
|
| 12 |
:data => {'oauth-metadata-import-target' => 'source', :action => 'oauth-metadata-import#apply'} %>
|
|
| 13 |
<p data-oauth-metadata-import-target="message"></p> |
|
| 2 | 14 | |
| 3 | 15 |
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %> |
| 4 | 16 |
<%= render :partial => 'form', :locals => { :f => f } %>
|
| 5 | 17 |
<%= submit_tag l(:button_create) %> |
| 6 | 18 |
<% end %> |
| 19 |
</div> |
|
| w/config/locales/en.yml | ||
|---|---|---|
| 1389 | 1389 |
text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again. |
| 1390 | 1390 |
text_oauth_implicit_permissions: View your name, login and primary email address |
| 1391 | 1391 |
text_oauth_info_scopes: Select the scopes this application may request. The application will not be allowed to do more than what is selected here. It will also always be limited by the roles and project memberships of the user who authorized it. |
| 1392 |
text_oauth_import_client_metadata_unknown_scopes: 'The following scopes are not available here and were ignored:' |
|
| 1392 | 1393 | |
| 1393 | 1394 |
default_role_manager: Manager |
| 1394 | 1395 |
default_role_developer: Developer |
| w/config/locales/ja.yml | ||
|---|---|---|
| 1468 | 1468 |
text_oauth_copy_secret_now: シークレットは今すぐ安全な場所にコピーしてください。今後は再表示できません。 |
| 1469 | 1469 |
text_oauth_implicit_permissions: あなたの名前・ログインID・メインのメールアドレスの閲覧 |
| 1470 | 1470 |
text_oauth_info_scopes: このアプリケーションが要求できるスコープを選択してください。アプリケーションは、ここで選択された範囲を超えることはできません。また、認可したユーザーのロールおよび参加しているプロジェクトによる制限も常に適用されます。 |
| 1471 |
text_oauth_import_client_metadata_unknown_scopes: '次のスコープはこのRedmineでは利用できないため無視されました:' |
|
| 1471 | 1472 |
twofa_already_setup: 二要素認証はすでに設定済みです。 |
| 1472 | 1473 |
permission_use_webhooks: Webhookの使用 |
| 1473 | 1474 |
label_webhook_plural: Webhook |
| w/test/system/oauth2_applications_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../application_system_test_case' |
|
| 21 | ||
| 22 |
class Oauth2ApplicationsSystemTest < ApplicationSystemTestCase |
|
| 23 |
def test_new_application_form_can_be_filled_from_client_metadata_file |
|
| 24 |
log_user('admin', 'admin')
|
|
| 25 |
with_settings :rest_api_enabled => '1' do |
|
| 26 |
visit '/oauth/applications/new' |
|
| 27 | ||
| 28 |
attach_metadata_file( |
|
| 29 |
'client_name' => 'Metadata App', |
|
| 30 |
'redirect_uris' => ['https://example.com/cb', 'https://example.net/cb'], |
|
| 31 |
'scope' => 'view_issues add_issues unknown_scope' |
|
| 32 |
) |
|
| 33 | ||
| 34 |
assert_equal 'Metadata App', find('#doorkeeper_application_name').value
|
|
| 35 |
assert_equal "https://example.com/cb\nhttps://example.net/cb", |
|
| 36 |
find('#doorkeeper_application_redirect_uri').value
|
|
| 37 |
assert find('#doorkeeper_application_scopes_view_issues').checked?
|
|
| 38 |
assert find('#doorkeeper_application_scopes_add_issues').checked?
|
|
| 39 |
assert_not find('#doorkeeper_application_scopes_edit_issues').checked?
|
|
| 40 |
# public permissions are always granted and must stay checked |
|
| 41 |
assert find('#doorkeeper_application_scopes_view_project').checked?
|
|
| 42 |
assert_selector 'p.flash.error', :text => /unknown_scope/ |
|
| 43 |
end |
|
| 44 |
end |
|
| 45 | ||
| 46 |
def test_importing_client_metadata_again_should_uncheck_scopes_not_listed |
|
| 47 |
log_user('admin', 'admin')
|
|
| 48 |
with_settings :rest_api_enabled => '1' do |
|
| 49 |
visit '/oauth/applications/new' |
|
| 50 | ||
| 51 |
check 'View Issues' |
|
| 52 |
check 'Add issues' |
|
| 53 |
attach_metadata_file('scope' => 'add_issues')
|
|
| 54 | ||
| 55 |
assert_not find('#doorkeeper_application_scopes_view_issues').checked?
|
|
| 56 |
assert find('#doorkeeper_application_scopes_add_issues').checked?
|
|
| 57 |
assert_no_selector 'p.flash.error' |
|
| 58 |
end |
|
| 59 |
end |
|
| 60 | ||
| 61 |
def test_invalid_client_metadata_file_should_show_an_error_and_keep_the_form_untouched |
|
| 62 |
log_user('admin', 'admin')
|
|
| 63 |
with_settings :rest_api_enabled => '1' do |
|
| 64 |
visit '/oauth/applications/new' |
|
| 65 | ||
| 66 |
fill_in 'Name', :with => 'Untouched' |
|
| 67 |
attach_metadata_file('not json')
|
|
| 68 | ||
| 69 |
assert_selector 'p.flash.error' |
|
| 70 |
assert_equal 'Untouched', find('#doorkeeper_application_name').value
|
|
| 71 |
end |
|
| 72 |
end |
|
| 73 | ||
| 74 |
private |
|
| 75 | ||
| 76 |
def attach_metadata_file(metadata) |
|
| 77 |
@metadata_file = Tempfile.new(['client_metadata', '.json']) |
|
| 78 |
@metadata_file.write(metadata.is_a?(String) ? metadata : metadata.to_json) |
|
| 79 |
@metadata_file.close |
|
| 80 |
# the file input is visually hidden behind the Import button |
|
| 81 |
attach_file 'oauth_metadata_file', @metadata_file.path, :make_visible => true |
|
| 82 |
end |
|
| 83 |
end |
|