diff --git a/app/components/admin/menu_component.html.erb b/app/components/admin/menu_component.html.erb index 750ee18ec..8e70d9cb7 100644 --- a/app/components/admin/menu_component.html.erb +++ b/app/components/admin/menu_component.html.erb @@ -110,6 +110,7 @@ <%= t("admin.menu.title_settings") %> <%= link_list( settings_link, + tenants_link, tags_link, geozones_link, images_link, diff --git a/app/components/admin/menu_component.rb b/app/components/admin/menu_component.rb index 4ef5d262f..9fc4c5319 100644 --- a/app/components/admin/menu_component.rb +++ b/app/components/admin/menu_component.rb @@ -1,5 +1,6 @@ class Admin::MenuComponent < ApplicationComponent include LinkListHelper + delegate :can?, to: :helpers private @@ -32,8 +33,8 @@ class Admin::MenuComponent < ApplicationComponent end def settings? - controllers_names = ["settings", "tags", "geozones", "images", "content_blocks", - "local_census_records", "imports"] + controllers_names = ["settings", "tenants", "tags", "geozones", "images", + "content_blocks", "local_census_records", "imports"] controllers_names.include?(controller_name) && controller.class.module_parent != Admin::Poll::Questions::Answers end @@ -300,6 +301,16 @@ class Admin::MenuComponent < ApplicationComponent ] end + def tenants_link + if can?(:read, Tenant) + [ + t("admin.menu.multitenancy"), + admin_tenants_path, + controller_name == "tenants" + ] + end + end + def tags_link [ t("admin.menu.proposals_topics"), diff --git a/app/components/admin/tenants/edit_component.html.erb b/app/components/admin/tenants/edit_component.html.erb new file mode 100644 index 000000000..f83758392 --- /dev/null +++ b/app/components/admin/tenants/edit_component.html.erb @@ -0,0 +1,3 @@ +<%= back_link_to admin_tenants_path %> +<%= header %> +<%= render Admin::Tenants::FormComponent.new(tenant) %> diff --git a/app/components/admin/tenants/edit_component.rb b/app/components/admin/tenants/edit_component.rb new file mode 100644 index 000000000..ee73145d4 --- /dev/null +++ b/app/components/admin/tenants/edit_component.rb @@ -0,0 +1,12 @@ +class Admin::Tenants::EditComponent < ApplicationComponent + include Header + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end + + def title + tenant.name + end +end diff --git a/app/components/admin/tenants/form_component.html.erb b/app/components/admin/tenants/form_component.html.erb new file mode 100644 index 000000000..1c01da639 --- /dev/null +++ b/app/components/admin/tenants/form_component.html.erb @@ -0,0 +1,7 @@ +<%= form_for [:admin, tenant] do |f| %> + <%= render "shared/errors", resource: tenant %> + + <%= f.text_field :name %> + <%= f.text_field :schema %> + <%= f.submit %> +<% end %> diff --git a/app/components/admin/tenants/form_component.rb b/app/components/admin/tenants/form_component.rb new file mode 100644 index 000000000..8db70a94b --- /dev/null +++ b/app/components/admin/tenants/form_component.rb @@ -0,0 +1,7 @@ +class Admin::Tenants::FormComponent < ApplicationComponent + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end +end diff --git a/app/components/admin/tenants/index_component.html.erb b/app/components/admin/tenants/index_component.html.erb new file mode 100644 index 000000000..005eb377d --- /dev/null +++ b/app/components/admin/tenants/index_component.html.erb @@ -0,0 +1,27 @@ +<%= header do %> + <%= link_to t("admin.tenants.index.create"), new_admin_tenant_path %> +<% end %> + + + + + + + + + + + + <% @tenants.each do |tenant| %> + + + + + + <% end %> + +
<%= attribute_name(:name) %><%= attribute_name(:schema) %><%= t("admin.shared.actions") %>
<%= tenant.name %><%= tenant.schema %> + <%= render Admin::TableActionsComponent.new(tenant, actions: [:edit]) do |actions| %> + <%= actions.action(:show, text: t("admin.shared.view"), path: root_url(host: tenant.host)) %> + <% end %> +
diff --git a/app/components/admin/tenants/index_component.rb b/app/components/admin/tenants/index_component.rb new file mode 100644 index 000000000..eb37a6832 --- /dev/null +++ b/app/components/admin/tenants/index_component.rb @@ -0,0 +1,18 @@ +class Admin::Tenants::IndexComponent < ApplicationComponent + include Header + attr_reader :tenants + + def initialize(tenants) + @tenants = tenants + end + + def title + t("admin.menu.multitenancy") + end + + private + + def attribute_name(attribute) + Tenant.human_attribute_name(attribute) + end +end diff --git a/app/components/admin/tenants/new_component.html.erb b/app/components/admin/tenants/new_component.html.erb new file mode 100644 index 000000000..f83758392 --- /dev/null +++ b/app/components/admin/tenants/new_component.html.erb @@ -0,0 +1,3 @@ +<%= back_link_to admin_tenants_path %> +<%= header %> +<%= render Admin::Tenants::FormComponent.new(tenant) %> diff --git a/app/components/admin/tenants/new_component.rb b/app/components/admin/tenants/new_component.rb new file mode 100644 index 000000000..d0ebacf4d --- /dev/null +++ b/app/components/admin/tenants/new_component.rb @@ -0,0 +1,12 @@ +class Admin::Tenants::NewComponent < ApplicationComponent + include Header + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end + + def title + t("admin.tenants.new.title") + end +end diff --git a/app/controllers/admin/tenants_controller.rb b/app/controllers/admin/tenants_controller.rb new file mode 100644 index 000000000..020118ebf --- /dev/null +++ b/app/controllers/admin/tenants_controller.rb @@ -0,0 +1,35 @@ +class Admin::TenantsController < Admin::BaseController + load_and_authorize_resource + + def index + @tenants = @tenants.order(:name) + end + + def new + end + + def edit + end + + def create + if @tenant.save + redirect_to admin_tenants_path, notice: t("admin.tenants.create.notice") + else + render :new + end + end + + def update + if @tenant.update(tenant_params) + redirect_to admin_tenants_path, notice: t("admin.tenants.update.notice") + else + render :edit + end + end + + private + + def tenant_params + params.require(:tenant).permit(:name, :schema) + end +end diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb index f6e2bb038..9e3556e80 100644 --- a/app/models/abilities/administrator.rb +++ b/app/models/abilities/administrator.rb @@ -134,6 +134,10 @@ module Abilities can :manage, LocalCensusRecord can [:create, :read], LocalCensusRecords::Import + + if Rails.application.config.multitenancy && Tenant.default? + can [:create, :read, :update], Tenant + end end end end diff --git a/app/models/tenant.rb b/app/models/tenant.rb index 59b26591c..87b4a2c63 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -40,12 +40,16 @@ class Tenant < ApplicationRecord end def self.current_host - if default? + host_for(current_schema) + end + + def self.host_for(schema) + if schema == "public" default_host elsif default_host == "localhost" - "#{current_schema}.lvh.me" + "#{schema}.lvh.me" else - "#{current_schema}.#{default_host}" + "#{schema}.#{default_host}" end end @@ -67,6 +71,10 @@ class Tenant < ApplicationRecord end end + def host + self.class.host_for(schema) + end + private def create_schema diff --git a/app/views/admin/tenants/edit.html.erb b/app/views/admin/tenants/edit.html.erb new file mode 100644 index 000000000..710ff10cb --- /dev/null +++ b/app/views/admin/tenants/edit.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::EditComponent.new(@tenant) %> diff --git a/app/views/admin/tenants/index.html.erb b/app/views/admin/tenants/index.html.erb new file mode 100644 index 000000000..51cd3b7a6 --- /dev/null +++ b/app/views/admin/tenants/index.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::IndexComponent.new(@tenants) %> diff --git a/app/views/admin/tenants/new.html.erb b/app/views/admin/tenants/new.html.erb new file mode 100644 index 000000000..be2d4f4ca --- /dev/null +++ b/app/views/admin/tenants/new.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::NewComponent.new(@tenant) %> diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml index 0e9f59e65..c3800181f 100644 --- a/config/locales/en/activerecord.yml +++ b/config/locales/en/activerecord.yml @@ -124,6 +124,9 @@ en: images: one: "Image" other: "Images" + tenant: + one: "tenant" + other: "tenants" topic: one: "Topic" other: "Topics" @@ -378,6 +381,8 @@ en: body: Body tag: name: "Type the name of the topic" + tenant: + schema: "Subdomain" topic: title: "Title" description: "Initial text" diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index b1b68b4b8..68cbbd3d5 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -785,6 +785,7 @@ en: comments: "Comments" local_census_records: Manage local census machine_learning: "AI / Machine learning" + multitenancy: Multitenancy administrators: index: title: Administrators @@ -1628,6 +1629,15 @@ en: notice: "Card updated successfully" destroy: notice: "Card removed successfully" + tenants: + create: + notice: Tenant created successfully + index: + create: Create tenant + new: + title: New tenant + update: + notice: Tenant updated successfully homepage: title: Homepage description: The active modules appear in the homepage in the same order as here. diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml index 58305df15..102beb8f4 100644 --- a/config/locales/es/activerecord.yml +++ b/config/locales/es/activerecord.yml @@ -124,6 +124,9 @@ es: images: one: "Imagen" other: "Imágenes" + tenant: + one: "entidad" + other: "entidades" topic: one: "Tema" other: "Temas" @@ -378,6 +381,8 @@ es: body: Contenido tag: name: "Escribe el nombre del tema" + tenant: + schema: "Subdominio" topic: title: "Título" description: "Texto inicial" diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index 08caffd41..cca53ce34 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -784,6 +784,7 @@ es: comments: "Comentarios" local_census_records: Gestionar censo local machine_learning: "IA / Machine learning" + multitenancy: Multientidad administrators: index: title: Administradores @@ -1627,6 +1628,15 @@ es: notice: "Tarjeta actualizada con éxito" destroy: notice: "Tarjeta eliminada con éxito" + tenants: + create: + notice: Entidad creada correctamente + index: + create: Crear entidad + new: + title: Nueva entidad + update: + notice: Entidad actualizada correctamente homepage: title: Homepage description: Los módulos activos aparecerán en la homepage en el mismo orden que aquí. diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 56a2cf77e..752942b82 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -277,6 +277,8 @@ namespace :admin do post :execute, on: :collection delete :cancel, on: :collection end + + resources :tenants, except: [:show, :destroy] end resolve "Milestone" do |milestone| diff --git a/spec/models/abilities/administrator_spec.rb b/spec/models/abilities/administrator_spec.rb index 4579aa4c8..4b8e2376f 100644 --- a/spec/models/abilities/administrator_spec.rb +++ b/spec/models/abilities/administrator_spec.rb @@ -164,4 +164,37 @@ describe Abilities::Administrator do it { should be_able_to(:destroy, SDG::Manager) } it { should be_able_to(:manage, Widget::Card) } + + describe "tenants" do + context "with multitenancy disabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) } + + it { should_not be_able_to :create, Tenant } + it { should_not be_able_to :read, Tenant } + it { should_not be_able_to :update, Tenant } + it { should_not be_able_to :destroy, Tenant } + end + + context "with multitenancy enabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(true) } + + it { should be_able_to :create, Tenant } + it { should be_able_to :read, Tenant } + it { should be_able_to :update, Tenant } + it { should_not be_able_to :destroy, Tenant } + + it "does not allow administrators from other tenants to manage tenants " do + create(:tenant, schema: "subsidiary") + + Tenant.switch("subsidiary") do + admin = create(:administrator).user + + expect(admin).not_to be_able_to :create, Tenant + expect(admin).not_to be_able_to :read, Tenant + expect(admin).not_to be_able_to :update, Tenant + expect(admin).not_to be_able_to :destroy, Tenant + end + end + end + end end diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb index a8f41428f..60a658ffa 100644 --- a/spec/models/tenant_spec.rb +++ b/spec/models/tenant_spec.rb @@ -133,6 +133,26 @@ describe Tenant do end end + describe ".host_for" do + before do + allow(Tenant).to receive(:default_url_options).and_return({ host: "consul.dev" }) + end + + it "returns the default host for the default schema" do + expect(Tenant.host_for("public")).to eq "consul.dev" + end + + it "returns the host with a subdomain on other schemas" do + expect(Tenant.host_for("uranus")).to eq "uranus.consul.dev" + end + + it "uses lvh.me for subdomains when the host is localhost" do + allow(Tenant).to receive(:default_url_options).and_return({ host: "localhost" }) + + expect(Tenant.host_for("uranus")).to eq "uranus.lvh.me" + end + end + describe ".run_on_each" do it "runs the code on all tenants, including the default one" do create(:tenant, schema: "andromeda") diff --git a/spec/system/admin/tenants_spec.rb b/spec/system/admin/tenants_spec.rb new file mode 100644 index 000000000..3dabdb7f4 --- /dev/null +++ b/spec/system/admin/tenants_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +describe "Tenants", :admin do + before { allow(Tenant).to receive(:default_host).and_return("localhost") } + + scenario "Create" do + visit admin_root_path + + within("#side_menu") do + click_link "Settings" + click_link "Multitenancy" + end + + click_link "Create tenant" + fill_in "Subdomain", with: "earth" + fill_in "Name", with: "Earthlings" + click_button "Create tenant" + + expect(page).to have_content "Tenant created successfully" + + within("tr", text: "earth") { click_link "View" } + + expect(current_host).to eq "http://earth.lvh.me" + expect(page).to have_current_path root_path + expect(page).to have_link "Sign in" + end + + scenario "Update" do + create(:tenant, schema: "moon") + + visit admin_tenants_path + within("tr", text: "moon") { click_link "Edit" } + + fill_in "Subdomain", with: "the-moon" + click_button "Update tenant" + + expect(page).to have_content "Tenant updated successfully" + + within("tr", text: "the-moon") { click_link "View" } + + expect(current_host).to eq "http://the-moon.lvh.me" + expect(page).to have_current_path root_path + expect(page).to have_link "Sign in" + end +end