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 %>
+
+
+
+
+ | <%= attribute_name(:name) %> |
+ <%= attribute_name(:schema) %> |
+ <%= t("admin.shared.actions") %> |
+
+
+
+
+ <% @tenants.each do |tenant| %>
+
+ | <%= 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 %>
+ |
+
+ <% 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