Add custom domains (#314)

This commit is contained in:
Riccardo Graziosi
2024-03-24 12:54:02 +01:00
committed by GitHub
parent d47c70f576
commit d17b45c5c4
31 changed files with 221 additions and 39 deletions

View File

@@ -1,4 +1,7 @@
require 'uri'
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include ApplicationHelper
include Pundit::Authorization include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
@@ -16,24 +19,14 @@ class ApplicationController < ActionController::Base
end end
def load_tenant_data def load_tenant_data
if Rails.application.multi_tenancy? current_tenant = get_tenant_from_request(request)
return if request.subdomain.blank? or RESERVED_SUBDOMAINS.include?(request.subdomain)
# Load the current tenant based on subdomain if current_tenant.status == "pending" and controller_name != "confirmation" and action_name != "show"
current_tenant = Tenant.find_by(subdomain: request.subdomain) redirect_to pending_tenant_path; return
end
if current_tenant.status == "pending" and controller_name != "confirmation" and action_name != "show" if current_tenant.status == "blocked"
redirect_to pending_tenant_path; return redirect_to blocked_tenant_path; return
end
if current_tenant.status == "blocked"
redirect_to blocked_tenant_path; return
end
redirect_to showcase_url unless current_tenant
else
# Load the one and only tenant
current_tenant = Tenant.first
end end
return unless current_tenant return unless current_tenant

View File

@@ -61,10 +61,10 @@ class OAuthsController < ApplicationController
if user if user
oauth_token = user.generate_oauth_token oauth_token = user.generate_oauth_token
redirect_to add_subdomain_to(method(:o_auth_sign_in_from_oauth_token_url), nil, {user_id: user.id, token: oauth_token}) redirect_to get_url_for(method(:o_auth_sign_in_from_oauth_token_url), options: { user_id: user.id, token: oauth_token })
else else
flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name) flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name)
redirect_to add_subdomain_to(method(:new_user_session_url)) redirect_to get_url_for(method(:new_user_session_url))
end end
elsif reason == 'test' elsif reason == 'test'

View File

@@ -1,3 +1,5 @@
require 'httparty'
class TenantsController < ApplicationController class TenantsController < ApplicationController
include ApplicationHelper include ApplicationHelper
@@ -66,6 +68,23 @@ class TenantsController < ApplicationController
@tenant = Current.tenant_or_raise! @tenant = Current.tenant_or_raise!
authorize @tenant authorize @tenant
# If the custom domain has changed, we need to provision SSL certificate
custom_domain_response = AddCustomDomainWorkflow.new(
new_custom_domain: params[:tenant][:custom_domain],
current_custom_domain: @tenant.custom_domain
).run
if custom_domain_response == false
render json: {
error: "Error adding custom domain"
}, status: :unprocessable_entity
return
end
# Since custom_domain is unique at db level, we need to set it to nil if it is blank
# to avoid unique constraint violation
params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank?
if @tenant.update(tenant_update_params) if @tenant.update(tenant_update_params)
render json: @tenant render json: @tenant
else else

View File

@@ -28,10 +28,44 @@ module ApplicationHelper
end end
end end
def add_subdomain_to(url_helper, resource=nil, options={}) def get_url_for(url_helper, resource: nil, disallow_custom_domain: false, options: {})
options[:subdomain] = Current.tenant_or_raise!.subdomain if Rails.application.multi_tenancy? custom_domain = Current.tenant.custom_domain
options[:host] = Rails.application.base_url
if Rails.application.multi_tenancy? && (custom_domain.blank? || disallow_custom_domain)
options[:subdomain] = Current.tenant.subdomain
end
if custom_domain.blank? || disallow_custom_domain
options[:host] = Rails.application.base_url
else
options[:host] = custom_domain
end
if Rails.application.base_url.include?('https')
options[:protocol] = 'https'
else
options[:protocol] = 'http'
end
resource ? url_helper.call(resource, options) : url_helper.call(options) resource ? url_helper.call(resource, options) : url_helper.call(options)
end end
def get_tenant_from_request(request)
if Rails.application.multi_tenancy?
request_host_splitted = request.host.split('.')
app_host_splitted = URI.parse(Rails.application.base_url).host.split('.')
if app_host_splitted.join('.') == request_host_splitted.last(app_host_splitted.length).join('.')
return if request.subdomain.blank? or RESERVED_SUBDOMAINS.include?(request.subdomain)
tenant = Tenant.find_by(subdomain: request.subdomain)
else
tenant = Tenant.find_by(custom_domain: request.host)
end
else
tenant = Tenant.first
end
tenant
end
end end

View File

@@ -50,6 +50,7 @@ interface UpdateTenantParams {
siteLogo?: string; siteLogo?: string;
tenantSetting?: ITenantSetting; tenantSetting?: ITenantSetting;
locale?: string; locale?: string;
customDomain?: string;
authenticityToken: string; authenticityToken: string;
} }
@@ -58,6 +59,7 @@ export const updateTenant = ({
siteLogo = null, siteLogo = null,
tenantSetting = null, tenantSetting = null,
locale = null, locale = null,
customDomain = null,
authenticityToken, authenticityToken,
}: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { }: UpdateTenantParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(tenantUpdateStart()); dispatch(tenantUpdateStart());
@@ -65,7 +67,8 @@ export const updateTenant = ({
const tenant = Object.assign({}, const tenant = Object.assign({},
siteName !== null ? { site_name: siteName } : null, siteName !== null ? { site_name: siteName } : null,
siteLogo !== null ? { site_logo: siteLogo } : null, siteLogo !== null ? { site_logo: siteLogo } : null,
locale !== null ? { locale } : null locale !== null ? { locale } : null,
customDomain !== null ? { custom_domain: customDomain } : null,
); );
try { try {

View File

@@ -17,6 +17,8 @@ import {
import { DangerText, SmallMutedText } from '../../common/CustomTexts'; import { DangerText, SmallMutedText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils'; import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import IBoardJSON from '../../../interfaces/json/IBoard'; import IBoardJSON from '../../../interfaces/json/IBoard';
import ActionLink from '../../common/ActionLink';
import { LearnMoreIcon } from '../../common/Icons';
export interface ISiteSettingsGeneralForm { export interface ISiteSettingsGeneralForm {
siteName: string; siteName: string;
@@ -27,6 +29,7 @@ export interface ISiteSettingsGeneralForm {
showVoteButtonInBoard: boolean; showVoteButtonInBoard: boolean;
showPoweredBy: boolean; showPoweredBy: boolean;
rootBoardId?: string; rootBoardId?: string;
customDomain?: string;
showRoadmapInHeader: boolean; showRoadmapInHeader: boolean;
collapseBoardsInHeader: string; collapseBoardsInHeader: string;
} }
@@ -45,6 +48,7 @@ interface Props {
brandDisplaySetting: string, brandDisplaySetting: string,
locale: string, locale: string,
rootBoardId: number, rootBoardId: number,
customDomain: string,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
collapseBoardsInHeader: string, collapseBoardsInHeader: string,
showVoteCount: boolean, showVoteCount: boolean,
@@ -66,7 +70,8 @@ const GeneralSiteSettingsP = ({
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { isDirty, isSubmitSuccessful, errors } formState: { isDirty, isSubmitSuccessful, errors },
watch,
} = useForm<ISiteSettingsGeneralForm>({ } = useForm<ISiteSettingsGeneralForm>({
defaultValues: { defaultValues: {
siteName: originForm.siteName, siteName: originForm.siteName,
@@ -77,6 +82,7 @@ const GeneralSiteSettingsP = ({
showVoteButtonInBoard: originForm.showVoteButtonInBoard, showVoteButtonInBoard: originForm.showVoteButtonInBoard,
showPoweredBy: originForm.showPoweredBy, showPoweredBy: originForm.showPoweredBy,
rootBoardId: originForm.rootBoardId, rootBoardId: originForm.rootBoardId,
customDomain: originForm.customDomain,
showRoadmapInHeader: originForm.showRoadmapInHeader, showRoadmapInHeader: originForm.showRoadmapInHeader,
collapseBoardsInHeader: originForm.collapseBoardsInHeader, collapseBoardsInHeader: originForm.collapseBoardsInHeader,
}, },
@@ -89,6 +95,7 @@ const GeneralSiteSettingsP = ({
data.brandDisplaySetting, data.brandDisplaySetting,
data.locale, data.locale,
Number(data.rootBoardId), Number(data.rootBoardId),
data.customDomain,
data.showRoadmapInHeader, data.showRoadmapInHeader,
data.collapseBoardsInHeader, data.collapseBoardsInHeader,
data.showVoteCount, data.showVoteCount,
@@ -101,6 +108,8 @@ const GeneralSiteSettingsP = ({
}); });
}; };
const customDomain = watch('customDomain');
return ( return (
<> <>
<Box> <Box>
@@ -186,6 +195,29 @@ const GeneralSiteSettingsP = ({
</select> </select>
</div> </div>
<div className="formGroup">
<label htmlFor="customDomain">{ getLabel('tenant', 'custom_domain') }</label>
<input
{...register('customDomain')}
id="customDomain"
className="formControl"
/>
{
originForm.customDomain !== customDomain && customDomain !== '' &&
<div style={{marginTop: 16}}>
<SmallMutedText>
{ I18n.t('site_settings.general.custom_domain_help', { domain: customDomain }) }
</SmallMutedText>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/custom-domain', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.general.custom_domain_learn_more')}
</ActionLink>
</div>
}
</div>
<br /> <br />
<h4>{ I18n.t('site_settings.general.subtitle_header') }</h4> <h4>{ I18n.t('site_settings.general.subtitle_header') }</h4>

View File

@@ -17,6 +17,7 @@ const mapDispatchToProps = (dispatch: any) => ({
brandDisplaySetting: TenantSettingBrandDisplay, brandDisplaySetting: TenantSettingBrandDisplay,
locale: string, locale: string,
rootBoardId: number, rootBoardId: number,
customDomain: string,
showRoadmapInHeader: boolean, showRoadmapInHeader: boolean,
collapseBoardsInHeader: TenantSettingCollapseBoardsInHeader, collapseBoardsInHeader: TenantSettingCollapseBoardsInHeader,
showVoteCount: boolean, showVoteCount: boolean,
@@ -37,6 +38,7 @@ const mapDispatchToProps = (dispatch: any) => ({
collapse_boards_in_header: collapseBoardsInHeader, collapse_boards_in_header: collapseBoardsInHeader,
}, },
locale, locale,
customDomain,
authenticityToken, authenticityToken,
})); }));
}, },

View File

@@ -3,6 +3,7 @@ interface ITenant {
siteName: string; siteName: string;
siteLogo: string; siteLogo: string;
locale: string; locale: string;
customDomain?: string;
} }
export default ITenant; export default ITenant;

View File

@@ -4,6 +4,7 @@ interface ITenantJSON {
site_logo: string; site_logo: string;
brand_display_setting: string; brand_display_setting: string;
locale: string; locale: string;
custom_domain?: string;
} }
export default ITenantJSON; export default ITenantJSON;

View File

@@ -27,9 +27,9 @@ class OAuth < ApplicationRecord
# for this reason, we don't preprend tenant subdomain # for this reason, we don't preprend tenant subdomain
# but rather use the "login" subdomain # but rather use the "login" subdomain
if self.is_default? if self.is_default?
o_auth_callback_url(id, host: Rails.application.base_url, subdomain: "login") get_url_for(method(:o_auth_callback_url), resource: id, disallow_custom_domain: true, options: { subdomain: "login", host: Rails.application.base_url })
else else
add_subdomain_to(method(:o_auth_callback_url), id) get_url_for(method(:o_auth_callback_url), resource: id, disallow_custom_domain: true)
end end
end end

View File

@@ -19,6 +19,7 @@ class Tenant < ApplicationRecord
validates :site_name, presence: true validates :site_name, presence: true
validates :subdomain, presence: true, uniqueness: true validates :subdomain, presence: true, uniqueness: true
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :custom_domain, format: { with: /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/ }, uniqueness: true, allow_blank: true, allow_nil: true
accepts_nested_attributes_for :tenant_setting, update_only: true accepts_nested_attributes_for :tenant_setting, update_only: true

View File

@@ -5,7 +5,7 @@ class TenantPolicy < ApplicationPolicy
def permitted_attributes_for_update def permitted_attributes_for_update
if user.admin? if user.admin?
[:site_name, :site_logo, :locale] [:site_name, :site_logo, :locale, :custom_domain]
else else
[] []
end end

View File

@@ -4,6 +4,6 @@
<p> <p>
<%= link_to t('mailers.devise.confirmation_instructions.action'), <%= link_to t('mailers.devise.confirmation_instructions.action'),
add_subdomain_to(method(:confirmation_url), @resource, { confirmation_token: @token }) get_url_for(method(:confirmation_url), resource: @resource, options: { confirmation_token: @token })
%> %>
</p> </p>

View File

@@ -4,7 +4,7 @@
<p> <p>
<%= link_to t('mailers.devise.reset_password.action'), <%= link_to t('mailers.devise.reset_password.action'),
add_subdomain_to(method(:edit_password_url), @resource, { reset_password_token: @token }) get_url_for(method(:edit_password_url), resource: @resource, options: { reset_password_token: @token })
%> %>
</p> </p>

View File

@@ -6,6 +6,6 @@
<p> <p>
<%= link_to 'Unlock my account', <%= link_to 'Unlock my account',
add_subdomain_to(method(:unlock_url), @resource, { unlock_token: @token }) get_url_for(method(:unlock_url), resource: @resource, options: { unlock_token: @token })
%> %>
</p> </p>

View File

@@ -5,7 +5,7 @@
</head> </head>
<body> <body>
<div style="margin: 16px auto; text-align: center;"> <div style="margin: 16px auto; text-align: center;">
<a href="<%= add_subdomain_to(method(:root_url)) %>"> <a href="<%= get_url_for(method(:root_url)) %>">
<%= image_tag('https://raw.githubusercontent.com/astuto/astuto-assets/main/logo-64.png', alt: 'Astuto Logo', size: 64) %> <%= image_tag('https://raw.githubusercontent.com/astuto/astuto-assets/main/logo-64.png', alt: 'Astuto Logo', size: 64) %>
</a> </a>
</div> </div>

View File

@@ -13,6 +13,7 @@
showVoteButtonInBoard: @tenant_setting.show_vote_button_in_board, showVoteButtonInBoard: @tenant_setting.show_vote_button_in_board,
showPoweredBy: @tenant_setting.show_powered_by, showPoweredBy: @tenant_setting.show_powered_by,
rootBoardId: @tenant_setting.root_board_id.to_s, rootBoardId: @tenant_setting.root_board_id.to_s,
customDomain: @tenant.custom_domain,
showRoadmapInHeader: @tenant_setting.show_roadmap_in_header, showRoadmapInHeader: @tenant_setting.show_roadmap_in_header,
collapseBoardsInHeader: @tenant_setting.collapse_boards_in_header, collapseBoardsInHeader: @tenant_setting.collapse_boards_in_header,
locale: @tenant.locale locale: @tenant.locale

View File

@@ -4,7 +4,7 @@
<%= <%=
t( t(
'mailers.user.unsubscribe_from_post_html', 'mailers.user.unsubscribe_from_post_html',
href: link_to(t('mailers.user.unsubscribe_link'), add_subdomain_to(method(:post_url), post)) href: link_to(t('mailers.user.unsubscribe_link'), get_url_for(method(:post_url), resource: post))
) )
%> %>
</p> </p>

View File

@@ -4,7 +4,7 @@
<%= <%=
t( t(
'mailers.user.unsubscribe_from_site_html', 'mailers.user.unsubscribe_from_site_html',
href: link_to(t('mailers.user.unsubscribe_link'), add_subdomain_to(method(:edit_user_registration_url))) href: link_to(t('mailers.user.unsubscribe_link'), get_url_for(method(:edit_user_registration_url)))
).html_safe ).html_safe
%> %>
</p> </p>

View File

@@ -7,7 +7,7 @@
<%= render 'user_mailer/quoted_text', text: @comment.body %> <%= render 'user_mailer/quoted_text', text: @comment.body %>
<p> <p>
<%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %> <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @comment.post) %>
</p> </p>
<%= render 'user_mailer/closing' %> <%= render 'user_mailer/closing' %>

View File

@@ -9,7 +9,7 @@
</p> </p>
<p> <p>
<%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @post) %> <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @post) %>
</p> </p>
<%= render 'user_mailer/closing' %> <%= render 'user_mailer/closing' %>

View File

@@ -7,7 +7,7 @@
<%= render 'user_mailer/quoted_text', text: @comment.body %> <%= render 'user_mailer/quoted_text', text: @comment.body %>
<p> <p>
<%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %> <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @comment.post) %>
</p> </p>
<%= render 'user_mailer/closing' %> <%= render 'user_mailer/closing' %>

View File

@@ -7,7 +7,7 @@
<%= render 'user_mailer/quoted_text', text: @comment.body %> <%= render 'user_mailer/quoted_text', text: @comment.body %>
<p> <p>
<%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %> <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @comment.post) %>
</p> </p>
<%= render 'user_mailer/closing' %> <%= render 'user_mailer/closing' %>

View File

@@ -0,0 +1,48 @@
class AddCustomDomainWorkflow
include HTTParty
attr_accessor :new_custom_domain, :current_custom_domain
def initialize(new_custom_domain: "", current_custom_domain: "")
@new_custom_domain = new_custom_domain
@current_custom_domain = current_custom_domain
end
def make_request(method, domain)
return unless method == "POST" || method == "DELETE"
HTTParty.send(
method.downcase,
ENV["ASTUTO_CNAME_API_URL"],
headers: {
"api_key" => ENV["ASTUTO_CNAME_API_KEY"],
"Accept" => "application/json",
},
query: { "domain" => domain.downcase }
)
end
def run
return true unless Rails.application.multi_tenancy?
return true if @new_custom_domain == @current_custom_domain
return true if Tenant.exists?(custom_domain: @new_custom_domain)
begin
# Add new custom domain...
if @new_custom_domain.present?
response = make_request("POST", @new_custom_domain)
return false unless response.success?
end
# ... and remove the current one
if @current_custom_domain.present?
make_request("DELETE", @current_custom_domain)
end
return true
rescue => e
return false
end
end
end

View File

@@ -3,6 +3,8 @@ Rails.application.configure do
config.hosts << ".localhost:3000" config.hosts << ".localhost:3000"
config.hosts << ".lvh.me:3000" # used to test oauth strategies in development config.hosts << ".lvh.me:3000" # used to test oauth strategies in development
config.hosts << ".ngrok-free.app"
config.hosts << ".riggraz.dev"
# 0 if using localhost, 1 if using lvh.me # 0 if using localhost, 1 if using lvh.me
config.action_dispatch.tld_length = 0 config.action_dispatch.tld_length = 0

View File

@@ -116,6 +116,7 @@ en:
site_logo: 'Site logo' site_logo: 'Site logo'
subdomain: 'Subdomain' subdomain: 'Subdomain'
locale: 'Language' locale: 'Language'
custom_domain: 'Custom domain'
tenant_setting: tenant_setting:
brand_display: 'Display' brand_display: 'Display'
show_vote_count: 'Show vote count to users' show_vote_count: 'Show vote count to users'

View File

@@ -158,6 +158,8 @@ en:
collapse_boards_in_header_no_collapse: 'Never' collapse_boards_in_header_no_collapse: 'Never'
collapse_boards_in_header_always_collapse: 'Always' collapse_boards_in_header_always_collapse: 'Always'
subtitle_visibility: 'Visibility' subtitle_visibility: 'Visibility'
custom_domain_help: 'In your DNS settings, add a CNAME record pointing "%{domain}" to "cname.astuto.io"'
custom_domain_learn_more: 'Learn how to configure a custom domain'
show_vote_count_help: 'If you enable this setting, users will be able to see the vote count of posts. This may incentivize users to vote on already popular posts, leading to a snowball effect.' show_vote_count_help: 'If you enable this setting, users will be able to see the vote count of posts. This may incentivize users to vote on already popular posts, leading to a snowball effect.'
show_vote_button_in_board_help: 'If you enable this setting, users will be able to vote posts from the board page. This may incentivize users to vote on more posts, leading to a higher number of votes but of lower significance.' show_vote_button_in_board_help: 'If you enable this setting, users will be able to vote posts from the board page. This may incentivize users to vote on more posts, leading to a higher number of votes but of lower significance.'
boards: boards:

View File

@@ -0,0 +1,6 @@
class ChangeTenantCustomUrlColumn < ActiveRecord::Migration[6.1]
def change
rename_column :tenants, :custom_url, :custom_domain
add_index :tenants, :custom_domain, unique: true
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2024_03_03_103945) do ActiveRecord::Schema.define(version: 2024_03_21_171022) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -153,10 +153,11 @@ ActiveRecord::Schema.define(version: 2024_03_03_103945) do
t.string "site_logo" t.string "site_logo"
t.string "subdomain", null: false t.string "subdomain", null: false
t.string "locale", default: "en" t.string "locale", default: "en"
t.string "custom_url" t.string "custom_domain"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.integer "status" t.integer "status"
t.index ["custom_domain"], name: "index_tenants_on_custom_domain", unique: true
end end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|

View File

@@ -4,6 +4,7 @@ FactoryBot.define do
site_logo { "" } site_logo { "" }
sequence(:subdomain) { |n| "mysubdomain#{n}" } sequence(:subdomain) { |n| "mysubdomain#{n}" }
locale { "en" } locale { "en" }
custom_url { "" } custom_domain { nil }
status { "active" }
end end
end end

View File

@@ -35,4 +35,38 @@ RSpec.describe Tenant, type: :model do
tenant.subdomain = tenant2.subdomain tenant.subdomain = tenant2.subdomain
expect(tenant).to be_invalid expect(tenant).to be_invalid
end end
it 'may have a valid custom domain' do
expect(tenant.custom_domain).to be_nil
tenant.custom_domain = ''
expect(tenant).to be_valid
tenant.custom_domain = 'example.com'
expect(tenant).to be_valid
tenant.custom_domain = 'subdomain.example.com'
expect(tenant).to be_valid
tenant.custom_domain = 'sub.subdomain.example.com'
expect(tenant).to be_valid
tenant.custom_domain = 'com'
expect(tenant).to be_invalid
tenant.custom_domain = 'https://example.com'
expect(tenant).to be_invalid
tenant.custom_domain = 'example.com/sub'
expect(tenant).to be_invalid
tenant.custom_domain = 'example.com.'
expect(tenant).to be_invalid
tenant.custom_domain = 'example..com'
expect(tenant).to be_invalid
tenant.custom_domain = '.example.com'
expect(tenant).to be_invalid
end
end end