Improve flow and guidance for setting up a file storage

pull/10602/head
Frank Bergmann 3 years ago committed by Wieland Lindenthal
parent 5fa08c5b70
commit c84980b257
No known key found for this signature in database
GPG Key ID: 7ACCABE64832A0C6
  1. 56
      app/contracts/oauth_clients/create_contract.rb
  2. 34
      app/contracts/oauth_clients/delete_contract.rb
  3. 31
      app/models/oauth_client.rb
  4. 39
      app/services/oauth_clients/create_service.rb
  5. 33
      app/services/oauth_clients/delete_service.rb
  6. 31
      app/services/oauth_clients/set_attributes_service.rb
  7. 4
      config/locales/js-en.yml
  8. 40
      db/migrate/20220503093844_create_oauth_client.rb
  9. 109
      modules/storages/app/controllers/storages/admin/oauth_clients_controller.rb
  10. 25
      modules/storages/app/controllers/storages/admin/storages_controller.rb
  11. 3
      modules/storages/app/models/storages/storage.rb
  12. 54
      modules/storages/app/views/storages/admin/storages/_form.html.erb
  13. 34
      modules/storages/app/views/storages/admin/storages/edit.html.erb
  14. 6
      modules/storages/app/views/storages/admin/storages/new.html.erb
  15. 38
      modules/storages/app/views/storages/admin/storages/new_oauth_client.html.erb
  16. 37
      modules/storages/app/views/storages/admin/storages/show.html.erb
  17. 10
      modules/storages/config/locales/en.yml
  18. 4
      modules/storages/config/routes.rb
  19. 0
      modules/storages/db/migrate/20220113144323_create_storage.rb
  20. 0
      modules/storages/db/migrate/20220113144759_create_file_links.rb
  21. 103
      modules/storages/spec/features/admin_storages_spec.rb
  22. 87
      spec/contracts/oauth_clients/create_contract_spec.rb
  23. 43
      spec/contracts/oauth_clients/delete_contract_spec.rb
  24. 35
      spec/factories/oauth_client_factory.rb
  25. 36
      spec/services/oauth_clients/create_service_spec.rb
  26. 36
      spec/services/oauth_clients/delete_service_spec.rb
  27. 111
      spec/services/oauth_clients/set_attributes_service_spec.rb

@ -0,0 +1,56 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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 OAuthClients
class CreateContract < ::ModelContract
include ActiveModel::Validations
include ActiveModel::Validations
attribute :client_id, writable: true
validates :client_id, presence: true, length: { maximum: 255 }
attribute :client_secret, writable: true
validates :client_secret, presence: true, length: { maximum: 255 }
attribute :integration_type, writable: true
validates :integration_type, presence: true
attribute :integration_id, writable: true
validates :integration_id, presence: true
validate :validate_user_allowed
private
def validate_user_allowed
unless user.admin? && user.active?
errors.add :base, :error_unauthorized
end
end
end
end

@ -0,0 +1,34 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# See also: base_contract.rb for comments
module OAuthClients
class DeleteContract < ::DeleteContract
delete_permission :admin
end
end

@ -0,0 +1,31 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
class OAuthClient < ApplicationRecord
belongs_to :integration, polymorphic: true
end

@ -0,0 +1,39 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# The logic for creating storage was extracted from the controller and put into
# a service: https://dev.to/joker666/ruby-on-rails-pattern-service-objects-b19
# Purpose: create and persist a Storages::Storage record
# Used by: Storages::Admin::StoragesController#create, could also be used by the
# API in the future.
# Reference: https://www.openproject.org/docs/development/concepts/contracted-services/
# The comments here are also valid for the other *_service.rb files
module OAuthClients
class CreateService < ::BaseServices::Create
end
end

@ -0,0 +1,33 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# See also: create_service.rb for comments
module OAuthClients
class DeleteService < ::BaseServices::Delete
end
end

@ -0,0 +1,31 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
# See also: create_service.rb for comments
class OAuthClients::SetAttributesService < ::BaseServices::SetAttributes
end

@ -1289,3 +1289,7 @@ en:
remove: 'Remove'
drop_modal:
Close: 'Close'
storages:
enter_oauth2_client_secret: 'Please enter your OAuth2 client secret'

@ -0,0 +1,40 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
class CreateOAuthClient < ActiveRecord::Migration[6.1]
def change
create_table :oauth_clients do |t|
t.string :client_id, null: false
t.string :client_secret, null: false
t.references :integration,
polymorphic: true, index: { unique: true }, null: false
t.timestamps
end
end
end

@ -0,0 +1,109 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
class Storages::Admin::OAuthClientsController < ApplicationController
# See https://guides.rubyonrails.org/layouts_and_rendering.html for reference on layout
layout 'admin'
# Before executing any action below: Make sure the current user is an admin
# and set the @<controller_name> variable to the object referenced in the URL.
before_action :require_admin
before_action :find_storage
before_action :delete_current_oauth_client, only: %i[create]
# menu_item is defined in the Redmine::MenuManager::MenuController
# module, included from ApplicationController.
# The menu item is defined in the engine.rb
menu_item :storages_admin_settings
# Show the admin page to create a new OAuthClient object.
def new
@oauth_client = ::OAuthClients::SetAttributesService.new(user: User.current,
model: OAuthClient.new,
contract_class: EmptyContract)
.call
.result
render '/storages/admin/storages/new_oauth_client'
end
# Actually create a OAuthClient object.
# Use service pattern to create a new OAuthClient
# See also: https://www.openproject.org/docs/development/concepts/contracted-services/
# Called by: Global app/config/routes.rb to serve Web page
def create
service_result = ::OAuthClients::CreateService.new(user: User.current)
.call(permitted_oauth_client_params.merge(integration: @storage))
@oauth_client = service_result.result
if service_result.success?
flash[:notice] = I18n.t(:notice_successful_create)
# admin_settings_storage_path is automagically created by Ruby routes.
redirect_to admin_settings_storage_path(@storage)
else
@errors = service_result.errors
render '/storages/admin/storages/new_oauth_client'
end
end
# Used by: admin layout
# Breadcrumbs is something like OpenProject > Admin > Storages.
# This returns the name of the last part (Storages admin page)
def default_breadcrumb
ActionController::Base.helpers.link_to(t('storages.label_oauth_client_details'), admin_settings_storage_oauth_client_path)
end
# See: default_breadcrumb above
# Defines whether to show breadcrumbs on the page or not.
def show_local_breadcrumb
true
end
private
def ensure_storages_module_active
return if OpenProject::FeatureDecisions.storages_module_active?
raise ActionController::RoutingError, 'Not Found'
end
# Called by create and update above in order to check if the
# update parameters are correctly set.
def permitted_oauth_client_params
params
.require(:oauth_client)
.permit('client_id', 'client_secret')
end
def find_storage
@storage = ::Storages::Storage.find(params[:storage_id])
end
def delete_current_oauth_client
::OAuthClients::DeleteService.new(user: User.current, model: @storage.oauth_client).call if @storage.oauth_client
end
end

@ -43,6 +43,8 @@ class Storages::Admin::StoragesController < ApplicationController
before_action :require_admin
before_action :find_model_object, only: %i[show destroy edit update]
before_action :set_shortened_secret, only: %i[show edit update]
# menu_item is defined in the Redmine::MenuManager::MenuController
# module, included from ApplicationController.
# The menu item is defined in the engine.rb
@ -89,8 +91,12 @@ class Storages::Admin::StoragesController < ApplicationController
if service_result.success?
flash[:notice] = I18n.t(:notice_successful_create)
# admin_settings_storage_path is automagically created by Ruby routes.
redirect_to admin_settings_storage_path(@object)
if @object.oauth_client
# admin_settings_storage_path is automagically created by Ruby routes.
redirect_to admin_settings_storage_path(@object)
else
redirect_to new_admin_settings_storage_oauth_client_path(@object)
end
else
@errors = service_result.errors
render :new
@ -135,6 +141,15 @@ class Storages::Admin::StoragesController < ApplicationController
redirect_to admin_settings_storages_path
end
# Show first two and last two characters, with **** in the middle
def shortened_secret(secret)
result = ""
if secret.is_a?(String) && secret.present?
result = "#{secret[...2]}****#{secret[-2...]}"
end
result
end
# Used by: admin layout
# Breadcrumbs is something like OpenProject > Admin > Storages.
# This returns the name of the last part (Storages admin page)
@ -165,6 +180,10 @@ class Storages::Admin::StoragesController < ApplicationController
def permitted_storage_params
params
.require(:storages_storage)
.permit('name', 'provider_type', 'host')
.permit('name', 'provider_type', 'host', 'oauth_client_id', 'oauth_client_secret')
end
def set_shortened_secret
@short_secret = shortened_secret(@object.oauth_client&.client_secret.to_s)
end
end

@ -51,6 +51,9 @@ class Storages::Storage < ApplicationRecord
has_many :projects_storages, dependent: :destroy, class_name: 'Storages::ProjectStorage'
# We can get the list of projects with this Storage enabled.
has_many :projects, through: :projects_storages
# The OAuth client credentials that OpenProject will use to obtain user specific
# access tokens from the storage server, i.e a Nextcloud serer.
has_one :oauth_client, as: :integration, dependent: :destroy
PROVIDER_TYPES = [
PROVIDER_TYPE_NEXTCLOUD = 'nextcloud'.freeze

@ -31,32 +31,30 @@ See COPYRIGHT and LICENSE files for more details.
<%= error_messages_for_contract @object, @errors %>
<section class="form--section">
<div class="form--field -required">
<%= f.select :provider_type,
::Storages::Storage::PROVIDER_TYPES.map { |provider_type| [I18n.t("storages.provider_types.#{provider_type}"), provider_type] },
{
selected: ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD,
container_class: '-slim'
},
{
disabled: @object.persisted? || ::Storages::Storage::PROVIDER_TYPES.count == 1
} %>
</div>
<div class="form--field -required">
<%= f.text_field :name, required: true, container_class: '-slim' %>
<span class="form--field-instructions">
<%= t('storages.instructions.name') %>
</span>
</div>
<div class="form--field -required">
<%= f.text_field :host,
type: :url,
required: true,
pattern: ".{1,255}",
placeholder: "https://my-file-storage.com", container_class: '-wide' %>
<span class="form--field-instructions">
<%= t('storages.instructions.host') %>
</span>
</div>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t(:label_general) %></legend>
<div class="form--field -required">
<%= f.select :provider_type,
::Storages::Storage::PROVIDER_TYPES.map { |provider_type| [I18n.t("storages.provider_types.#{provider_type}"), provider_type] },
{
selected: ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD,
container_class: '-slim'
},
{
disabled: @object.persisted? || ::Storages::Storage::PROVIDER_TYPES.count == 1
} %>
</div>
<div class="form--field -required">
<%= f.text_field :name, required: true, container_class: '-slim' %>
<span class="form--field-instructions"><%= t('storages.instructions.name') %></span>
</div>
<div class="form--field -required">
<%= f.text_field :host,
type: :url,
required: true,
pattern: ".{1,255}",
placeholder: "https://my-file-storage.com", container_class: '-wide' %>
<span class="form--field-instructions"><%= t('storages.instructions.host') %></span>
</div>
</fieldset>
</section>

@ -1,4 +1,10 @@
<!-- Standard Ruby view, please see the controller for comments -->
<!-- We want to hide client_secret from the user while allowing the user to edit the other attributes -->
<% content_for :header_tags do %>
<meta name="required_script" content="storage_form" />
<% end %>
<% html_title t(:label_administration), t("project_module_storages"), t('label_edit_x', x: @object.name) %>
<% local_assigns[:additional_breadcrumb] = @object.name %>
<%= toolbar title: t('label_edit_x', x: @object.name) %>
@ -7,3 +13,31 @@
<%= render partial: 'form', locals: { f: f } %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_details') %></legend>
<% if @object.oauth_client %>
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_id') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.oauth_client.client_id %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @short_secret %></span>
</div>
</div>
</div>
<%= link_to(t("button_replace"),
new_admin_settings_storage_oauth_client_path(@object),
data: { confirm: t(:'storages.confirm_replace_oauth_client')},
class: 'button -with-icon icon-reload' ) %>
<% else %>
<%= link_to(t("js.label_create"), new_admin_settings_storage_oauth_client_path(@object), class: 'button -with-icon icon-add') %>
<% end %>
</fieldset>
</section>

@ -5,5 +5,9 @@
<%= labelled_tabular_form_for @object, url: admin_settings_storages_path(@object) do |f| -%>
<%= render partial: 'form', locals: { f: f } %>
<%= styled_button_tag t(:button_create), class: "-highlight -with-icon icon-checkmark" %>
<% if @object.oauth_client %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% else %>
<%= styled_button_tag t(:button_continue), class: "-highlight -with-icon icon-arrow-right3" %>
<% end %>
<% end %>

@ -0,0 +1,38 @@
<!-- Standard Ruby view, please see the controller for comments -->
<% html_title t(:label_administration), t("project_module_storages"), @storage.name, "#{t("storages.provider_types.#{@storage.provider_type}")} #{t("storages.label_oauth_client_details")}" %>
<% local_assigns[:additional_breadcrumb] = "#{t("storages.provider_types.#{@storage.provider_type}")} #{t("storages.label_oauth_client_details")}" %>
<%= toolbar title: "#{t("storages.provider_types.#{@storage.provider_type}")} #{t("storages.label_oauth_client_details")}" %>
<%= labelled_tabular_form_for @oauth_client, url: admin_settings_storage_oauth_client_path do |f| -%>
<div class="form--field -required">
<%= f.text_field :client_id,
label: t('storages.label_oauth_client_id'),
required: true,
size: 40,
container_class: '-wide' %>
<span class="form--field-instructions">
<%= t("storages.instructions.#{@storage.provider_type}.oauth_client_id") %>
<%= link_to "#{t("storages.provider_types.#{@storage.provider_type}")} / #{t("storages.instructions.#{@storage.provider_type}.administration")} / #{t("storages.instructions.#{@storage.provider_type}.oauth2_clients")}",
URI::join(@storage.host, "settings/admin/security#oauth2").to_s,
target: "blank" %>
</span>
</div>
<div class="form--field -required">
<%= f.text_field :client_secret,
label: t('storages.label_oauth_client_secret'),
required: true,
size: 40,
container_class: '-wide' %>
<span class="form--field-instructions">
<%= t("storages.instructions.#{@storage.provider_type}.oauth_client_secret") %>
<%= link_to "#{t("storages.provider_types.#{@storage.provider_type}")} / #{t("storages.instructions.#{@storage.provider_type}.administration")} / #{t("storages.instructions.#{@storage.provider_type}.oauth2_clients")}",
URI::join(@storage.host, "settings/admin/security#oauth2").to_s,
target: "blank" %>
</span>
</div>
<% if @storage.oauth_client %>
<%= styled_button_tag t(:button_replace), class: "-highlight -with-icon icon-checkmark" %>
<% else %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
<% end %>

@ -51,25 +51,31 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"><%= t(:label_general) %></h3>
</div>
</div>
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= t(:'storages.label_name') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.name %></span>
</div>
<div class="attributes-key-value--value -text"><span><%= @object.name %></span></div>
</div>
<div class="attributes-key-value--key"><%= t(:'storages.label_provider_type') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= t(:"storages.provider_types.#{@object.provider_type}") %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t(:'storages.label_host') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= link_to @object.host, @object.host %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t(:'storages.label_creator') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
@ -84,3 +90,28 @@ See COPYRIGHT and LICENSE files for more details.
</div>
</div>
</div>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_details') %></h3>
</div>
</div>
<% if @object.oauth_client %>
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_id') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.oauth_client.client_id %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @short_secret %></span>
</div>
</div>
</div>
<% else %>
<%= t("storages.oauth_client_details_missing") %>
<% end %>
</div>

@ -49,6 +49,11 @@ en:
setting_up_storages: "For setting up file storages, please visit"
setting_up_storages_non_admin: "Administrators can set up file storages in Administration / File Storages."
all_available_storages_already_added: "All available storages are already added to the project."
nextcloud:
administration: Administration
oauth2_clients: OAuth2 clients
oauth_client_id: Copy the value from
oauth_client_secret: Copy the value from
delete_warning:
storage: >
Are you sure you want to delete this storage? This will also delete the storage from all projects where it is used.
@ -61,11 +66,16 @@ en:
label_file_links: "File links"
label_name: "Name"
label_host: "Host"
label_oauth_client_details: "OAuth client details"
label_provider_type: "Provider type"
label_new_storage: "New storage"
label_storage: "Storage"
label_storages: "Storages"
no_results: "No storages set up, yet."
label_oauth_client_id: "OAuth Client ID"
label_oauth_client_secret: "OAuth Client Secret"
provider_types:
label: "Provider type"
nextcloud: "Nextcloud"
confirm_replace_oauth_client: "Are you sure? All users will have to authorize again against the storage."
oauth_client_details_missing: "To complete the setup, please add OAuth client credentials from your storage."

@ -29,7 +29,9 @@
OpenProject::Application.routes.draw do
namespace :admin do
namespace :settings do
resources :storages, controller: '/storages/admin/storages'
resources :storages, controller: '/storages/admin/storages' do
resource :oauth_client, controller: '/storages/admin/oauth_clients', only: %i[new create]
end
end
end

@ -37,53 +37,130 @@ describe 'Admin storages', :enable_storages, :storage_server_helpers, type: :fea
it 'creates, edits and deletes storages', webmock: true do
visit admin_settings_storages_path
# Show empty storages list
expect(page).to have_title('File storages')
expect(page.find('.title-container')).to have_text('File storages')
expect(page).to have_text(I18n.t('storages.no_results'))
page.find('.toolbar .button--icon.icon-add').click
# Create a storage - happy path
expect(page).to have_title('New storage')
expect(page.find('.title-container')).to have_text('New storage')
expect(page).to have_select 'storages_storage[provider_type]', selected: 'Nextcloud', disabled: true
expect(page).to have_field('storages_storage[name]', with: 'Nextcloud')
# Test the happy path for a valid storage server (host).
# Mock a valid response (=200) for example.com, so the host validation should succeed
mock_server_capabilities_response("https://example.com")
page.find('#storages_storage_name').set("NC 1")
page.find('#storages_storage_host').set("https://example.com")
page.find('button[type=submit]').click
page.find('button[type=submit]', text: "Continue").click
# Add OAuthClient - Testing a number of different invalid states
# However, more detailed checks are performed in the service spec.
expect(page).to have_title("OAuth client details")
# Set the client_id but leave client_secret empty
page.find('#oauth_client_client_id').set("0123456789")
page.find('button[type=submit]').click
# Check that we're still on the same page
expect(page).to have_title("OAuth client details")
# Set client_id to be empty but set the client_secret
page.find('#oauth_client_client_id').set("")
page.find('#oauth_client_client_secret').set("1234567890")
page.find('button[type=submit]', text: 'Save').click
# Check that we're still on the same page
expect(page).to have_title("OAuth client details")
# Both client_id and client_secret valid
page.find('#oauth_client_client_id').set("0123456789")
page.find('#oauth_client_client_secret').set("1234567890")
page.find('button[type=submit]', text: 'Save').click
# Show details of a storage
created_storage = Storages::Storage.find_by(name: 'NC 1')
expect(page).to have_title("Nc 1")
expect(page.find('.title-container')).to have_text('NC 1')
expect(page).to have_text(admin.name)
expect(page).to have_text('https://example.com')
expect(page).to have_text(created_storage.created_at.localtime.strftime("%m/%d/%Y %I:%M %p"))
# Check for client_id and the shortened client secret
expect(page).to have_text("0123456789")
expect(page).to have_text("12****90")
# Edit storage again
page.find('.button--icon.icon-edit').click
expect(page).to have_title("Edit: NC 1")
expect(page.find('.title-container')).to have_text('Edit: NC 1')
mock_server_capabilities_response("https://other.example.com")
# Edit page - With option to replace the OAuth2 client
# Check presence of a "Replace" link and follow it
page.find('a', text: 'Replace').click
alert_text = page.driver.browser.switch_to.alert.text
expect(alert_text).to have_text("Are you sure?")
page.driver.browser.switch_to.alert.accept
# The form the new OAuth client shall be empty as we are creating a new one.
expect(page).not_to have_text("234567")
expect(page).not_to have_text("****")
page.find('#oauth_client_client_id').set("2345678901")
page.find('#oauth_client_client_secret').set("3456789012")
page.find('button[type=submit]', text: 'Replace').click
# Check for client_id and the shortened client secret
expect(page).to have_text("2345678901")
expect(page).to have_text("34****12")
# Test the behavior of a failed host validation with code 400 (Bad Request)
# simulating server not running Nextcloud
page.find('.button--icon.icon-edit').click
mock_server_capabilities_response("https://other.example.com", response_code: '400')
page.find('#storages_storage_name').set("Other NC")
page.find('#storages_storage_host').set("https://other.example.com")
page.find('button[type=submit]').click
page.find('button[type=submit]', text: "Save").click
expect(page).to have_title("Edit: Other NC")
expect(page.find('.title-container')).to have_text('Edit: Other NC')
expect(page).to have_selector('.op-toast--content')
expect(page).to have_text("error prohibited this Storage from being saved")
# Edit page - Check for failed Nextcloud Version
# Test the behavior of a Nextcloud server with major version too low
mock_server_capabilities_response("https://old.example.com", response_nextcloud_major_version: 18)
page.find('#storages_storage_name').set("Old NC")
page.find('#storages_storage_host').set("https://old.example.com")
page.find('button[type=submit]', text: "Save").click
expect(page).to have_title("Edit: Old NC")
expect(page).to have_selector('.op-toast')
version_err = I18n.t('activerecord.errors.models.storages/storage.attributes.host.minimal_nextcloud_version_unmet')
expect(page).to have_text(version_err)
# Edit page - save working storage
# Restore the mocked working server example.com
page.find('#storages_storage_host').set("https://example.com")
page.find('#storages_storage_name').set("Other NC")
page.find('button[type=submit]', text: "Save").click
created_storage = Storages::Storage.find_by(name: 'Other NC')
expect(page).to have_title("Other Nc")
expect(page.find('.title-container')).to have_text('Other NC')
expect(page).to have_text('https://other.example.com')
expect(page).to have_text(admin.name)
expect(page).to have_text('https://example.com')
expect(page).to have_text(created_storage.created_at.localtime.strftime("%m/%d/%Y %I:%M %p"))
# List of storages
page.find("ul.op-breadcrumb li", text: "File storages").click
expect(page).to have_title "File storages"
expect(page.find('.title-container')).to have_text('File storages')
expect(page).to have_text('Other NC')
expect(page).to have_text('https://other.example.com')
expect(page).to have_text(admin.name)
# Go to Other NC again
page.find("a", text: 'Other NC').click
expect(page).to have_current_path admin_settings_storage_path(created_storage)
# Delete on List page
page.find('.button--icon.icon-delete').click
alert_text = page.driver.browser.switch_to.alert.text
@ -92,5 +169,7 @@ describe 'Admin storages', :enable_storages, :storage_server_helpers, type: :fea
expect(page).to have_current_path(admin_settings_storages_path)
expect(page).not_to have_text("Other NC")
# Also check that there are no more OAuthClient instances anymore
expect(OAuthClient.all.count).to eq(0)
end
end

@ -0,0 +1,87 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require_module_spec_helper
require 'contracts/shared/model_contract_shared_context'
describe ::OAuthClients::CreateContract do
include_context 'ModelContract shared context'
let(:current_user) { create(:admin) }
let(:client_id) { "1234567889" }
let(:client_secret) { "asdfasdfasdf" }
let(:integration) { build_stubbed :storage }
let(:oauth_client) do
build(:oauth_client, client_id:, client_secret:, integration:)
end
let(:contract) { described_class.new(oauth_client, current_user) }
it_behaves_like 'contract is valid for active admins and invalid for regular users'
describe 'validations' do
context 'when all attributes are valid' do
include_examples 'contract is valid'
end
%i[client_id client_secret].each do |attribute_name|
context 'when client_id is invalid' do
context 'as it is too long' do
let(attribute_name) { 'X' * 257 }
include_examples 'contract is invalid', attribute_name => :too_long
end
context 'as it is empty' do
let(attribute_name) { '' }
include_examples 'contract is invalid', attribute_name => :blank
end
context 'as it is nil' do
let(attribute_name) { nil }
include_examples 'contract is invalid', attribute_name => :blank
end
end
end
context 'with integration (polymorphic attribute) linked' do
let(:integration) { create :storage }
include_examples 'contract is valid'
end
context 'without integration (polymorphic attribute)' do
let(:integration) { nil }
include_examples 'contract is invalid', { integration_id: :blank, integration_type: :blank }
end
end
end

@ -0,0 +1,43 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require_module_spec_helper
require 'contracts/shared/model_contract_shared_context'
# This DeleteContract spec just tests if the user is _allowed_
# to execute the operation.
describe ::OAuthClients::DeleteContract do
include_context 'ModelContract shared context'
let(:oauth_client) { create :oauth_client }
let(:contract) { described_class.new(oauth_client, current_user) }
# Generic checks that the contract is valid for valid admin, but invalid otherwise
it_behaves_like 'contract is valid for active admins and invalid for regular users'
end

@ -0,0 +1,35 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
FactoryBot.define do
factory :oauth_client, class: '::OAuthClient' do
sequence(:client_id) { |n| "1234567890-#{n}" }
sequence(:client_secret) { |n| "2345678901-#{n}" }
integration factory: :storage
end
end

@ -0,0 +1,36 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require 'services/base_services/behaves_like_create_service'
describe ::OAuthClients::CreateService, type: :model do
it_behaves_like 'BaseServices create service' do
let(:factory) { :oauth_client }
end
end

@ -0,0 +1,36 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
require 'services/base_services/behaves_like_delete_service'
describe ::OAuthClients::DeleteService, type: :model do
it_behaves_like 'BaseServices delete service' do
let(:factory) { :oauth_client }
end
end

@ -0,0 +1,111 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 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.
#++
require 'spec_helper'
describe ::OAuthClients::SetAttributesService, type: :model do
let(:current_user) { build_stubbed(:admin) }
let(:contract_instance) do
contract = instance_double(::OAuthClients::CreateContract, 'contract_instance')
allow(contract)
.to receive(:validate)
.and_return(contract_valid)
allow(contract)
.to receive(:errors)
.and_return(contract_errors)
contract
end
let(:contract_errors) { instance_double(ActiveModel::Errors, 'contract_errors') }
let(:contract_valid) { true }
let(:model_valid) { true }
let(:instance) do
described_class.new(user: current_user, model: model_instance, contract_class:, contract_options: {})
end
let(:model_instance) { ::OAuthClient.new }
let(:contract_class) do
allow(::OAuthClients::CreateContract)
.to receive(:new)
.and_return(contract_instance)
::OAuthClients::CreateContract
end
let(:params) { {} }
before do
allow(model_instance)
.to receive(:valid?)
.and_return(model_valid)
end
subject { instance.call(params) }
it 'returns the instance as the result' do
expect(subject.result)
.to eql model_instance
end
it 'is a success' do
expect(subject)
.to be_success
end
context 'with params' do
let(:params) do
{
client_id: '0123456789-client_id',
client_secret: '1234567890-client_secret'
}
end
before do
subject
end
it 'assigns the params' do
expect(model_instance.client_id).to eq '0123456789-client_id'
expect(model_instance.client_secret).to eq '1234567890-client_secret'
end
end
context 'with an invalid contract' do
let(:contract_valid) { false }
it 'returns failure' do
expect(subject).not_to be_success
end
it "returns the contract's errors" do
expect(subject.errors)
.to eql(contract_errors)
end
end
end
Loading…
Cancel
Save