Project

General

Profile

Feature #44167 » oauth2-client-metadata-import.patch

[Agileware]Shun(ji) Nishitani, 2026-06-12 13:03

View differences:

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
    (1-1/1)