diff --git a/Gemfile b/Gemfile index 35b9e21a8ad..ea8c8d5f4dd 100644 --- a/Gemfile +++ b/Gemfile @@ -210,6 +210,7 @@ gem "validate_url" # Storages support code gem "dry-container" +gem "dry-monads" # ActiveRecord extension which adds typecasting to store accessors gem "store_attribute", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index ba216ee21a8..fa7d48ddd91 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -476,6 +476,10 @@ GEM concurrent-ruby (~> 1.0) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) + dry-monads (1.6.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) dry-types (1.7.2) bigdecimal (~> 3.0) concurrent-ruby (~> 1.0) @@ -1195,6 +1199,7 @@ DEPENDENCIES doorkeeper (~> 5.7.0) dotenv-rails dry-container + dry-monads email_validator (~> 2.2.3) equivalent-xml (~> 0.6) erb_lint diff --git a/modules/storages/app/components/storages/admin/sidebar/connection_validation_component.html.erb b/modules/storages/app/components/storages/admin/sidebar/connection_validation_component.html.erb new file mode 100644 index 00000000000..d5e13502875 --- /dev/null +++ b/modules/storages/app/components/storages/admin/sidebar/connection_validation_component.html.erb @@ -0,0 +1,68 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +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. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + component_wrapper do + flex_layout do |container| + container.with_row do + flex_layout do |heading| + heading.with_row do + render(Primer::Beta::Heading.new(tag: :h4)) { I18n.t("storages.connection_validation.title") } + end + end + end + + container.with_row(mt: 2) do + primer_form_with( + model: @storage, + url: validate_connection_admin_settings_storage_connection_validation_path(@storage), + method: :post, + data: { turbo: true } + ) do + flex_layout do |form| + form.with_row do + render(OpTurbo::FrameComponent.new(id: :connection_validation_result)) + end + + form.with_row(mt: 2) do + render(Primer::Beta::Button.new( + scheme: :default, + block: :true, + type: :submit, + )) do |button| + button.with_leading_visual_icon(icon: "tasklist") + I18n.t("storages.connection_validation.action") + end + end + end + end + end + end + end +%> diff --git a/modules/storages/app/components/storages/admin/sidebar/connection_validation_component.rb b/modules/storages/app/components/storages/admin/sidebar/connection_validation_component.rb new file mode 100644 index 00000000000..f8f0176d52f --- /dev/null +++ b/modules/storages/app/components/storages/admin/sidebar/connection_validation_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + module Admin + module Sidebar + class ConnectionValidationComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(storage:) + super(storage) + @storage = storage + end + end + end + end +end diff --git a/modules/storages/app/components/storages/admin/sidebar_component.html.erb b/modules/storages/app/components/storages/admin/sidebar_component.html.erb index 3b19f1e32f1..720b0e4c00c 100644 --- a/modules/storages/app/components/storages/admin/sidebar_component.html.erb +++ b/modules/storages/app/components/storages/admin/sidebar_component.html.erb @@ -1,6 +1,7 @@ <%= component_wrapper do render(Primer::OpenProject::BorderGrid.new) do |border_grid| + border_grid.with_row { render(Storages::Admin::Sidebar::ConnectionValidationComponent.new(storage: @storage)) } border_grid.with_row { render(Storages::Admin::Sidebar::HealthStatusComponent.new(storage: @storage)) } border_grid.with_row { render(Storages::Admin::Sidebar::HealthNotificationsComponent.new(storage: @storage)) } end diff --git a/modules/storages/app/controllers/storages/admin/connection_validation_controller.rb b/modules/storages/app/controllers/storages/admin/connection_validation_controller.rb new file mode 100644 index 00000000000..b18dcf1c648 --- /dev/null +++ b/modules/storages/app/controllers/storages/admin/connection_validation_controller.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + module Admin + class ConnectionValidationController < ApplicationController + include OpTurbo::ComponentStream + include Dry::Monads[:maybe] + + using Peripherals::ServiceResultRefinements + + layout "admin" + + before_action :require_admin + + model_object OneDriveStorage + + before_action :find_model_object, only: %i[validate_connection] + + def validate_connection + @result = maybe_is_not_configured + .or { drive_id_wrong } + .or { tenant_id_wrong } + .or { client_id_wrong } + .or { client_secret_wrong } + .or { request_failed_with_unknown_error } + .value_or(ConnectionValidation.new(icon: "check-circle", + scheme: :success, + description: I18n.t("storages.connection_validation.success"))) + + respond_to do |format| + format.turbo_stream + end + end + + private + + def query + @query ||= Peripherals::Registry + .resolve("#{@storage.short_provider_type}.queries.files") + .call(storage: @storage, auth_strategy:, folder: root_folder) + end + + def maybe_is_not_configured + return None() if @storage.configured? + + Some(ConnectionValidation.new(icon: :alert, + scheme: :warning, + description: I18n.t("storages.connection_validation.not_configured"))) + end + + def drive_id_wrong + return None() if query.result != :not_found + + Some(ConnectionValidation.new(icon: :skip, + scheme: :danger, + description: I18n.t("storages.connection_validation.drive_id_wrong"))) + end + + def tenant_id_wrong + return None() if query.result != :unauthorized + + payload = JSON.parse(query.error_payload) + return None() if payload["error"] != "invalid_request" + + tenant_id_string = "Tenant '#{@storage.tenant_id}' not found." + return None() unless payload["error_description"].include?(tenant_id_string) + + Some(ConnectionValidation.new(icon: :skip, + scheme: :danger, + description: I18n.t("storages.connection_validation.tenant_id_wrong"))) + end + + def client_id_wrong + return None() if query.result != :unauthorized + + payload = JSON.parse(query.error_payload) + return None() if payload["error"] != "unauthorized_client" + + Some(ConnectionValidation.new(icon: :skip, + scheme: :danger, + description: I18n.t("storages.connection_validation.client_id_wrong"))) + end + + def client_secret_wrong + return None() if query.result != :unauthorized + + payload = JSON.parse(query.error_payload) + return None() if payload["error"] != "invalid_client" + + Some(ConnectionValidation.new(icon: :skip, + scheme: :danger, + description: I18n.t("storages.connection_validation.client_secret_wrong"))) + end + + def request_failed_with_unknown_error + return None() if query.success? + + Rails.logger.error("Connection validation failed with unknown error:\n\t" \ + "status: #{query.result}\n\tresponse: #{query.error_payload}") + + Some(ConnectionValidation.new(icon: :skip, + scheme: :danger, + description: I18n.t("storages.connection_validation.unknown_error"))) + end + + def find_model_object(object_id = :storage_id) + super + @storage = @object + end + + def root_folder + Peripherals::ParentFolder.new("/") + end + + def auth_strategy + Peripherals::Registry.resolve("#{@storage.short_provider_type}.authentication.userless").call + end + end + end +end diff --git a/modules/storages/app/models/storages/connection_validation.rb b/modules/storages/app/models/storages/connection_validation.rb new file mode 100644 index 00000000000..9840279040e --- /dev/null +++ b/modules/storages/app/models/storages/connection_validation.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# 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. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + ConnectionValidation = Data.define(:icon, :scheme, :description) +end diff --git a/modules/storages/app/views/storages/admin/connection_validation/validate_connection.turbo_stream.erb b/modules/storages/app/views/storages/admin/connection_validation/validate_connection.turbo_stream.erb new file mode 100644 index 00000000000..8c6bf8749e2 --- /dev/null +++ b/modules/storages/app/views/storages/admin/connection_validation/validate_connection.turbo_stream.erb @@ -0,0 +1,32 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2024 the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +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. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= turbo_stream.update :connection_validation_result do %> + <%= render(Primer::Alpha::Banner.new(icon: @result.icon, scheme: @result.scheme)) { @result.description } %> +<% end %> diff --git a/modules/storages/config/locales/en.yml b/modules/storages/config/locales/en.yml index 8e0acddddc4..e92dd48a1f7 100644 --- a/modules/storages/config/locales/en.yml +++ b/modules/storages/config/locales/en.yml @@ -116,6 +116,16 @@ en: subscribe: Subscribe title: Email notifications unsubscribe: Unsubscribe + connection_validation: + title: Connection validation + action: Validate connection + not_configured: The connection could not be validated. Please finish configuration first. + drive_id_wrong: The configured drive id could not be found. Please check the configuration. + tenant_id_wrong: The configured directory (tenant) id is invalid. Please check the configuration. + client_id_wrong: The configured OAuth 2 client id is invalid. Please check the configuration. + client_secret_wrong: The configured OAuth 2 client secret is invalid. Please check the configuration. + unknown_error: The connection could not be validated. An unknown error occurred. Please check the server logs for further information. + success: The connection works as expected. help_texts: project_folder: The project folder is the default folder for file uploads for this project. Users can nevertheless still upload files to other locations. instructions: diff --git a/modules/storages/config/routes.rb b/modules/storages/config/routes.rb index 30617b2ca52..04b2d533977 100644 --- a/modules/storages/config/routes.rb +++ b/modules/storages/config/routes.rb @@ -38,11 +38,18 @@ Rails.application.routes.draw do post :finish_setup end - resource :automatically_managed_project_folders, controller: "/storages/admin/automatically_managed_project_folders", - only: %i[new create edit update] + resource :automatically_managed_project_folders, + controller: "/storages/admin/automatically_managed_project_folders", + only: %i[new create edit update] resource :access_management, controller: "/storages/admin/access_management", only: %i[new create edit update] + resource :connection_validation, + controller: "/storages/admin/connection_validation", + only: [] do + post :validate_connection, on: :member + end + get :select_provider, on: :collection member do