diff --git i/app/javascript/controllers/oauth_metadata_import_controller.js w/app/javascript/controllers/oauth_metadata_import_controller.js
new file mode 100644
index 000000000..37528f668
--- /dev/null
+++ w/app/javascript/controllers/oauth_metadata_import_controller.js
@@ -0,0 +1,70 @@
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = ["source", "name", "redirectUri", "message"];
+ static values = {
+ invalidMessage: String,
+ unknownScopesMessage: String
+ };
+
+ openFilePicker(event) {
+ event.preventDefault();
+ this.sourceTarget.click();
+ }
+
+ async apply() {
+ const file = this.sourceTarget.files[0];
+ if (!file) return;
+
+ let metadata;
+ try {
+ metadata = JSON.parse(await file.text());
+ } catch {
+ metadata = null;
+ }
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
+ this.showMessage(this.invalidMessageValue, true);
+ return;
+ }
+
+ if (typeof metadata.client_name === "string") {
+ this.nameTarget.value = metadata.client_name;
+ }
+ if (Array.isArray(metadata.redirect_uris)) {
+ this.redirectUriTarget.value = metadata.redirect_uris.join("\n");
+ }
+
+ const unknownScopes = this.applyScopes(metadata.scope);
+ if (unknownScopes.length > 0) {
+ this.showMessage(`${this.unknownScopesMessageValue} ${unknownScopes.join(", ")}`, true);
+ } else {
+ this.clearMessage();
+ }
+ }
+
+ applyScopes(scope) {
+ if (typeof scope !== "string") return [];
+
+ const requestedScopes = scope.split(/\s+/).filter((s) => s.length > 0);
+ const knownScopes = new Set();
+ const checkboxes = this.element.querySelectorAll('input[type="checkbox"][name="doorkeeper_application[scopes][]"]');
+ checkboxes.forEach((checkbox) => {
+ knownScopes.add(checkbox.value);
+ // disabled checkboxes are public permissions that are always granted
+ if (checkbox.disabled) return;
+
+ checkbox.checked = requestedScopes.includes(checkbox.value);
+ });
+ return requestedScopes.filter((s) => !knownScopes.has(s));
+ }
+
+ showMessage(text, isError) {
+ this.messageTarget.textContent = text;
+ this.messageTarget.className = isError ? "flash error" : "flash notice";
+ }
+
+ clearMessage() {
+ this.messageTarget.textContent = "";
+ this.messageTarget.removeAttribute("class");
+ }
+}
diff --git i/app/views/doorkeeper/applications/_form.html.erb w/app/views/doorkeeper/applications/_form.html.erb
index b501a7401..2df464ebe 100644
--- i/app/views/doorkeeper/applications/_form.html.erb
+++ w/app/views/doorkeeper/applications/_form.html.erb
@@ -1,9 +1,10 @@
<%= error_messages_for 'application' %>
-
<%= f.text_field :name, :required => true %>
+
<%= f.text_field :name, :required => true, :data => {'oauth-metadata-import-target' => 'name'} %>
- <%= f.textarea :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri' %>
+ <%= f.textarea :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri',
+ :data => {'oauth-metadata-import-target' => 'redirectUri'} %>
<%= t('doorkeeper.applications.help.redirect_uri') %>
diff --git i/app/views/doorkeeper/applications/edit.html.erb w/app/views/doorkeeper/applications/edit.html.erb
index aebc1a841..32075c7d5 100644
--- i/app/views/doorkeeper/applications/edit.html.erb
+++ w/app/views/doorkeeper/applications/edit.html.erb
@@ -1,6 +1,19 @@
+
+
+ <%= link_to sprite_icon('import', l(:button_import)), '#',
+ :class => 'icon icon-import',
+ :data => {:action => 'oauth-metadata-import#openFilePicker'} %>
+
<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
+<%= file_field_tag 'oauth_metadata_file', :class => 'hidden',
+ :accept => '.json,application/json',
+ :data => {'oauth-metadata-import-target' => 'source', :action => 'oauth-metadata-import#apply'} %>
+
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<%= submit_tag l(:button_save) %>
<% end %>
+
diff --git i/app/views/doorkeeper/applications/new.html.erb w/app/views/doorkeeper/applications/new.html.erb
index e2a39ac93..4a78de92b 100644
--- i/app/views/doorkeeper/applications/new.html.erb
+++ w/app/views/doorkeeper/applications/new.html.erb
@@ -1,6 +1,19 @@
+
+
+ <%= link_to sprite_icon('import', l(:button_import)), '#',
+ :class => 'icon icon-import',
+ :data => {:action => 'oauth-metadata-import#openFilePicker'} %>
+
<%= title [l('label_oauth_application_plural'), oauth_applications_path], t('.title') %>
+<%= file_field_tag 'oauth_metadata_file', :class => 'hidden',
+ :accept => '.json,application/json',
+ :data => {'oauth-metadata-import-target' => 'source', :action => 'oauth-metadata-import#apply'} %>
+
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %>
<%= render :partial => 'form', :locals => { :f => f } %>
<%= submit_tag l(:button_create) %>
<% end %>
+
diff --git i/config/locales/en.yml w/config/locales/en.yml
index 2a30a9d3a..3a6e6d6de 100644
--- i/config/locales/en.yml
+++ w/config/locales/en.yml
@@ -1389,6 +1389,7 @@ en:
text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again.
text_oauth_implicit_permissions: View your name, login and primary email address
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.
+ text_oauth_import_client_metadata_unknown_scopes: 'The following scopes are not available here and were ignored:'
default_role_manager: Manager
default_role_developer: Developer
diff --git i/config/locales/ja.yml w/config/locales/ja.yml
index 05b9ab47d..8c0b0405d 100644
--- i/config/locales/ja.yml
+++ w/config/locales/ja.yml
@@ -1468,6 +1468,7 @@ ja:
text_oauth_copy_secret_now: シークレットは今すぐ安全な場所にコピーしてください。今後は再表示できません。
text_oauth_implicit_permissions: あなたの名前・ログインID・メインのメールアドレスの閲覧
text_oauth_info_scopes: このアプリケーションが要求できるスコープを選択してください。アプリケーションは、ここで選択された範囲を超えることはできません。また、認可したユーザーのロールおよび参加しているプロジェクトによる制限も常に適用されます。
+ text_oauth_import_client_metadata_unknown_scopes: '次のスコープはこのRedmineでは利用できないため無視されました:'
twofa_already_setup: 二要素認証はすでに設定済みです。
permission_use_webhooks: Webhookの使用
label_webhook_plural: Webhook
diff --git i/test/system/oauth2_applications_test.rb w/test/system/oauth2_applications_test.rb
new file mode 100644
index 000000000..802b4e573
--- /dev/null
+++ w/test/system/oauth2_applications_test.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006- Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+require_relative '../application_system_test_case'
+
+class Oauth2ApplicationsSystemTest < ApplicationSystemTestCase
+ def test_new_application_form_can_be_filled_from_client_metadata_file
+ log_user('admin', 'admin')
+ with_settings :rest_api_enabled => '1' do
+ visit '/oauth/applications/new'
+
+ attach_metadata_file(
+ 'client_name' => 'Metadata App',
+ 'redirect_uris' => ['https://example.com/cb', 'https://example.net/cb'],
+ 'scope' => 'view_issues add_issues unknown_scope'
+ )
+
+ assert_equal 'Metadata App', find('#doorkeeper_application_name').value
+ assert_equal "https://example.com/cb\nhttps://example.net/cb",
+ find('#doorkeeper_application_redirect_uri').value
+ assert find('#doorkeeper_application_scopes_view_issues').checked?
+ assert find('#doorkeeper_application_scopes_add_issues').checked?
+ assert_not find('#doorkeeper_application_scopes_edit_issues').checked?
+ # public permissions are always granted and must stay checked
+ assert find('#doorkeeper_application_scopes_view_project').checked?
+ assert_selector 'p.flash.error', :text => /unknown_scope/
+ end
+ end
+
+ def test_importing_client_metadata_again_should_uncheck_scopes_not_listed
+ log_user('admin', 'admin')
+ with_settings :rest_api_enabled => '1' do
+ visit '/oauth/applications/new'
+
+ check 'View Issues'
+ check 'Add issues'
+ attach_metadata_file('scope' => 'add_issues')
+
+ assert_not find('#doorkeeper_application_scopes_view_issues').checked?
+ assert find('#doorkeeper_application_scopes_add_issues').checked?
+ assert_no_selector 'p.flash.error'
+ end
+ end
+
+ def test_invalid_client_metadata_file_should_show_an_error_and_keep_the_form_untouched
+ log_user('admin', 'admin')
+ with_settings :rest_api_enabled => '1' do
+ visit '/oauth/applications/new'
+
+ fill_in 'Name', :with => 'Untouched'
+ attach_metadata_file('not json')
+
+ assert_selector 'p.flash.error'
+ assert_equal 'Untouched', find('#doorkeeper_application_name').value
+ end
+ end
+
+ private
+
+ def attach_metadata_file(metadata)
+ @metadata_file = Tempfile.new(['client_metadata', '.json'])
+ @metadata_file.write(metadata.is_a?(String) ? metadata : metadata.to_json)
+ @metadata_file.close
+ # the file input is visually hidden behind the Import button
+ attach_file 'oauth_metadata_file', @metadata_file.path, :make_visible => true
+ end
+end