Make it possible to disable tenants

Note we could use `acts_as_paranoid` with the `without_default_scope`
option, but we aren't doing so because it isn't possible to consider
deleted records in uniqueness validations with the paranoia gem [1].
I've added tests for these cases so we don't accidentally add
`acts_as_paranoid` in the future.

Also note we're extracting a `RowComponent` because, when
enabling/disabling a tenant, we're also enabling/disabling the link
pointing to its URL, and so we need to update the URL column after the
AJAX call.

[1] See issues 285 and 319 in https://github.com/rubysherpas/paranoia/
This commit is contained in:
Javi Martín
2022-12-19 16:32:31 +01:00
parent 0dac6ead77
commit 25435b0297
18 changed files with 204 additions and 11 deletions

View File

@@ -335,6 +335,7 @@ Rails/SkipsModelValidations:
ForbiddenMethods:
- update_attribute
Exclude:
- app/models/tenant.rb
- lib/acts_as_paranoid_aliases.rb
Rails/TimeZone:

View File

@@ -8,20 +8,14 @@
<th><%= attribute_name(:name) %></th>
<th><%= attribute_name(:schema) %></th>
<th><%= attribute_name(:url) %></th>
<th><%= t("admin.tenants.index.enabled") %></th>
<th><%= t("admin.shared.actions") %></th>
</tr>
</thead>
<tbody>
<% @tenants.each do |tenant| %>
<tr id="<%= dom_id(tenant) %>">
<td><%= tenant.name %></td>
<td><%= tenant.schema %></td>
<td><%= link_to tenant.host, root_url(host: tenant.host) %></td>
<td>
<%= render Admin::TableActionsComponent.new(tenant, actions: [:edit]) %>
</td>
</tr>
<%= render Admin::Tenants::RowComponent.new(tenant) %>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,9 @@
<tr id="<%= dom_id(tenant) %>">
<td><%= tenant.name %></td>
<td><%= tenant.schema %></td>
<td><%= link_to_unless tenant.hidden?, tenant.host, root_url(host: tenant.host) %></td>
<td><%= render Admin::Tenants::ToggleHiddenComponent.new(tenant) %></td>
<td>
<%= render Admin::TableActionsComponent.new(tenant, actions: [:edit]) %>
</td>
</tr>

View File

@@ -0,0 +1,7 @@
class Admin::Tenants::RowComponent < ApplicationComponent
attr_reader :tenant
def initialize(tenant)
@tenant = tenant
end
end

View File

@@ -0,0 +1 @@
<%= render Admin::ToggleSwitchComponent.new(action, tenant, pressed: enabled?, **options) %>

View File

@@ -0,0 +1,28 @@
class Admin::Tenants::ToggleHiddenComponent < ApplicationComponent
attr_reader :tenant
def initialize(tenant)
@tenant = tenant
end
private
def action
if enabled?
:hide
else
:restore
end
end
def options
{
method: :put,
"aria-label": t("admin.tenants.index.enable", tenant: tenant.name)
}
end
def enabled?
!tenant.hidden?
end
end

View File

@@ -27,6 +27,24 @@ class Admin::TenantsController < Admin::BaseController
end
end
def hide
@tenant.hide
respond_to do |format|
format.html { redirect_to admin_tenants_path, notice: t("admin.tenants.hide.notice") }
format.js { render template: "admin/tenants/toggle_enabled" }
end
end
def restore
@tenant.restore
respond_to do |format|
format.html { redirect_to admin_tenants_path, notice: t("admin.tenants.restore.notice") }
format.js { render template: "admin/tenants/toggle_enabled" }
end
end
private
def tenant_params

View File

@@ -136,7 +136,7 @@ module Abilities
can [:create, :read], LocalCensusRecords::Import
if Rails.application.config.multitenancy && Tenant.default?
can [:create, :read, :update], Tenant
can [:create, :read, :update, :hide, :restore], Tenant
end
end
end

View File

@@ -12,6 +12,8 @@ class Tenant < ApplicationRecord
after_update :rename_schema
after_destroy :destroy_schema
scope :only_hidden, -> { where.not(hidden_at: nil) }
def self.find_by_domain(host)
domain.find_by(schema: host)
end
@@ -20,6 +22,16 @@ class Tenant < ApplicationRecord
return nil unless Rails.application.config.multitenancy.present?
return nil if host.blank? || host.match?(Resolv::AddressRegex)
schema = schema_for(host)
if schema && only_hidden.find_by(schema: schema)
raise Apartment::TenantNotFound
else
schema
end
end
def self.schema_for(host)
host_without_www = host.delete_prefix("www.")
if find_by_domain(host)
@@ -137,6 +149,18 @@ class Tenant < ApplicationRecord
self.class.host_for(schema)
end
def hide
update_attribute(:hidden_at, Time.current)
end
def restore
update_attribute(:hidden_at, nil)
end
def hidden?
hidden_at.present?
end
private
def create_schema

View File

@@ -0,0 +1,4 @@
var replacement = $("<%= j render Admin::Tenants::RowComponent.new(@tenant) %>");
var row = $("#<%= dom_id(@tenant) %>");
row.html(replacement.html()).find(".toggle-switch [type='submit']").focus();

View File

@@ -1635,10 +1635,16 @@ en:
form:
use_subdomain: "Use a subdomain in the %{domain} domain to access this tenant"
use_domain: "Use a different domain to access this tenant"
hide:
notice: Tenant disabled successfully
index:
create: Create tenant
enable: "Enable tenant %{tenant}"
enabled: Enabled
new:
title: New tenant
restore:
notice: Tenant enabled successfully
update:
notice: Tenant updated successfully
homepage:

View File

@@ -1634,10 +1634,16 @@ es:
form:
use_subdomain: "Utiliza un subdominio en el dominio %{domain} para acceder a esta entidad"
use_domain: "Utiliza un dominio distinto para acceder a esta entidad"
hide:
notice: Entidad deshabilitada correctamente
index:
create: Crear entidad
enable: "Habilitar entidad %{tenant}"
enabled: Habilitada
new:
title: Nueva entidad
restore:
notice: Entidad habilitada correctamente
update:
notice: Entidad actualizada correctamente
homepage:

View File

@@ -284,7 +284,12 @@ namespace :admin do
delete :cancel, on: :collection
end
resources :tenants, except: [:show, :destroy]
resources :tenants, except: [:show, :destroy] do
member do
put :hide
put :restore
end
end
end
resolve "Milestone" do |milestone|

View File

@@ -0,0 +1,5 @@
class AddHiddenAtToTenants < ActiveRecord::Migration[6.0]
def change
add_column :tenants, :hidden_at, :datetime
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_11_20_123254) do
ActiveRecord::Schema.define(version: 2022_12_03_140136) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
@@ -1562,6 +1562,7 @@ ActiveRecord::Schema.define(version: 2022_11_20_123254) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "schema_type", default: 0, null: false
t.datetime "hidden_at"
t.index ["name"], name: "index_tenants_on_name", unique: true
t.index ["schema"], name: "index_tenants_on_schema", unique: true
end

View File

@@ -181,6 +181,8 @@ describe Abilities::Administrator do
it { should be_able_to :create, Tenant }
it { should be_able_to :read, Tenant }
it { should be_able_to :update, Tenant }
it { should be_able_to :hide, Tenant }
it { should be_able_to :restore, Tenant }
it { should_not be_able_to :destroy, Tenant }
context "administrators from other tenants" do
@@ -193,6 +195,8 @@ describe Abilities::Administrator do
it { should_not be_able_to :read, Tenant }
it { should_not be_able_to :update, Tenant }
it { should_not be_able_to :destroy, Tenant }
it { should_not be_able_to :hide, Tenant }
it { should_not be_able_to :restore, Tenant }
end
end
end

View File

@@ -117,6 +117,37 @@ describe Tenant do
expect(Tenant.resolve_host("www.consul.dev")).to eq "www.consul.dev"
end
it "raises an exception when accessing a hidden tenant using a subdomain" do
insert(:tenant, schema: "saturn", hidden_at: Time.current)
expect { Tenant.resolve_host("saturn.consul.dev") }.to raise_exception(Apartment::TenantNotFound)
end
it "raises an exception when accessing a hidden tenant using a domain" do
insert(:tenant, :domain, schema: "consul.dev", hidden_at: Time.current)
expect { Tenant.resolve_host("consul.dev") }.to raise_exception(Apartment::TenantNotFound)
end
it "raises an exception when accessing a hidden tenant using a domain starting with www" do
insert(:tenant, :domain, schema: "www.consul.dev", hidden_at: Time.current)
expect { Tenant.resolve_host("www.consul.dev") }.to raise_exception(Apartment::TenantNotFound)
end
it "raises an exception when accessing a hidden tenant with a domain and another tenant resolves to the same domain" do
insert(:tenant, :domain, schema: "saturn.consul.dev", hidden_at: Time.current)
insert(:tenant, schema: "saturn")
expect { Tenant.resolve_host("saturn.consul.dev") }.to raise_exception(Apartment::TenantNotFound)
end
it "ignores hidden tenants with nil as their schema" do
insert(:tenant, schema: nil, hidden_at: Time.current)
expect(Tenant.resolve_host("consul.dev")).to be nil
end
context "multitenancy disabled" do
before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) }
@@ -318,6 +349,11 @@ describe Tenant do
expect(build(:tenant, schema: "subdomainx")).not_to be_valid
end
it "is not valid with the schema of an already existing hidden record" do
insert(:tenant, schema: "subdomainx", hidden_at: Time.current)
expect(build(:tenant, schema: "subdomainx")).not_to be_valid
end
it "is not valid with an excluded subdomain" do
%w[mail public shared_extensions www].each do |subdomain|
tenant.schema = subdomain
@@ -345,6 +381,11 @@ describe Tenant do
expect(build(:tenant, name: "Name X")).not_to be_valid
end
it "is not valid with the name of an already existing hidden record" do
insert(:tenant, name: "Name X", hidden_at: Time.current)
expect(build(:tenant, name: "Name X")).not_to be_valid
end
context "Domain schema type" do
before { tenant.schema_type = :domain }

View File

@@ -63,4 +63,43 @@ describe "Tenants", :admin, :seed_tenants do
expect(page).to have_current_path root_path
expect(page).to have_link "Sign in"
end
scenario "Hide and restore", :show_exceptions do
create(:tenant, schema: "moon", name: "Moon")
visit admin_tenants_path
within("tr", text: "moon") do
expect(page).to have_content "Yes"
click_button "Enable tenant Moon"
expect(page).to have_content "No"
expect(page).not_to have_link "moon.lvh.me"
end
with_subdomain("moon") do
visit root_path
expect(page).to have_title "Not found"
end
visit admin_tenants_path
within("tr", text: "moon") do
expect(page).to have_content "No"
click_button "Enable tenant Moon"
expect(page).to have_content "Yes"
expect(page).to have_link "moon.lvh.me"
end
with_subdomain("moon") do
visit root_path
expect(page).to have_link "Sign in"
expect(page).not_to have_title "Not found"
end
end
end