diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 560a01ab..54b50ca9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,7 @@ +require 'uri' + class ApplicationController < ActionController::Base + include ApplicationHelper include Pundit::Authorization rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized @@ -16,24 +19,14 @@ class ApplicationController < ActionController::Base end def load_tenant_data - if Rails.application.multi_tenancy? - return if request.subdomain.blank? or RESERVED_SUBDOMAINS.include?(request.subdomain) + current_tenant = get_tenant_from_request(request) - # Load the current tenant based on subdomain - current_tenant = Tenant.find_by(subdomain: request.subdomain) + if current_tenant.status == "pending" and controller_name != "confirmation" and action_name != "show" + redirect_to pending_tenant_path; return + end - if current_tenant.status == "pending" and controller_name != "confirmation" and action_name != "show" - redirect_to pending_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 + if current_tenant.status == "blocked" + redirect_to blocked_tenant_path; return end return unless current_tenant diff --git a/app/controllers/o_auths_controller.rb b/app/controllers/o_auths_controller.rb index 678bc53a..160189d1 100644 --- a/app/controllers/o_auths_controller.rb +++ b/app/controllers/o_auths_controller.rb @@ -61,10 +61,10 @@ class OAuthsController < ApplicationController if user 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 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 elsif reason == 'test' diff --git a/app/controllers/tenants_controller.rb b/app/controllers/tenants_controller.rb index ec47586c..f6c10573 100644 --- a/app/controllers/tenants_controller.rb +++ b/app/controllers/tenants_controller.rb @@ -1,3 +1,5 @@ +require 'httparty' + class TenantsController < ApplicationController include ApplicationHelper @@ -66,6 +68,23 @@ class TenantsController < ApplicationController @tenant = Current.tenant_or_raise! 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) render json: @tenant else diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f1d9c38e..f33b8885 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -28,10 +28,44 @@ module ApplicationHelper end end - def add_subdomain_to(url_helper, resource=nil, options={}) - options[:subdomain] = Current.tenant_or_raise!.subdomain if Rails.application.multi_tenancy? - options[:host] = Rails.application.base_url + def get_url_for(url_helper, resource: nil, disallow_custom_domain: false, options: {}) + custom_domain = Current.tenant.custom_domain + + 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) 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 diff --git a/app/javascript/actions/Tenant/updateTenant.ts b/app/javascript/actions/Tenant/updateTenant.ts index 79202a1c..164694b1 100644 --- a/app/javascript/actions/Tenant/updateTenant.ts +++ b/app/javascript/actions/Tenant/updateTenant.ts @@ -50,6 +50,7 @@ interface UpdateTenantParams { siteLogo?: string; tenantSetting?: ITenantSetting; locale?: string; + customDomain?: string; authenticityToken: string; } @@ -58,6 +59,7 @@ export const updateTenant = ({ siteLogo = null, tenantSetting = null, locale = null, + customDomain = null, authenticityToken, }: UpdateTenantParams): ThunkAction> => async (dispatch) => { dispatch(tenantUpdateStart()); @@ -65,7 +67,8 @@ export const updateTenant = ({ const tenant = Object.assign({}, siteName !== null ? { site_name: siteName } : null, siteLogo !== null ? { site_logo: siteLogo } : null, - locale !== null ? { locale } : null + locale !== null ? { locale } : null, + customDomain !== null ? { custom_domain: customDomain } : null, ); try { diff --git a/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx index 0a972438..2459005f 100644 --- a/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx +++ b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx @@ -17,6 +17,8 @@ import { import { DangerText, SmallMutedText } from '../../common/CustomTexts'; import { getLabel, getValidationMessage } from '../../../helpers/formUtils'; import IBoardJSON from '../../../interfaces/json/IBoard'; +import ActionLink from '../../common/ActionLink'; +import { LearnMoreIcon } from '../../common/Icons'; export interface ISiteSettingsGeneralForm { siteName: string; @@ -27,6 +29,7 @@ export interface ISiteSettingsGeneralForm { showVoteButtonInBoard: boolean; showPoweredBy: boolean; rootBoardId?: string; + customDomain?: string; showRoadmapInHeader: boolean; collapseBoardsInHeader: string; } @@ -45,6 +48,7 @@ interface Props { brandDisplaySetting: string, locale: string, rootBoardId: number, + customDomain: string, showRoadmapInHeader: boolean, collapseBoardsInHeader: string, showVoteCount: boolean, @@ -66,7 +70,8 @@ const GeneralSiteSettingsP = ({ const { register, handleSubmit, - formState: { isDirty, isSubmitSuccessful, errors } + formState: { isDirty, isSubmitSuccessful, errors }, + watch, } = useForm({ defaultValues: { siteName: originForm.siteName, @@ -77,6 +82,7 @@ const GeneralSiteSettingsP = ({ showVoteButtonInBoard: originForm.showVoteButtonInBoard, showPoweredBy: originForm.showPoweredBy, rootBoardId: originForm.rootBoardId, + customDomain: originForm.customDomain, showRoadmapInHeader: originForm.showRoadmapInHeader, collapseBoardsInHeader: originForm.collapseBoardsInHeader, }, @@ -89,6 +95,7 @@ const GeneralSiteSettingsP = ({ data.brandDisplaySetting, data.locale, Number(data.rootBoardId), + data.customDomain, data.showRoadmapInHeader, data.collapseBoardsInHeader, data.showVoteCount, @@ -101,6 +108,8 @@ const GeneralSiteSettingsP = ({ }); }; + const customDomain = watch('customDomain'); + return ( <> @@ -186,6 +195,29 @@ const GeneralSiteSettingsP = ({ +
+ + + { + originForm.customDomain !== customDomain && customDomain !== '' && +
+ + { I18n.t('site_settings.general.custom_domain_help', { domain: customDomain }) } + + window.open('https://docs.astuto.io/custom-domain', '_blank')} + icon={} + > + {I18n.t('site_settings.general.custom_domain_learn_more')} + +
+ } +
+

{ I18n.t('site_settings.general.subtitle_header') }

diff --git a/app/javascript/containers/GeneralSiteSettings.tsx b/app/javascript/containers/GeneralSiteSettings.tsx index 681eef6d..408aa84f 100644 --- a/app/javascript/containers/GeneralSiteSettings.tsx +++ b/app/javascript/containers/GeneralSiteSettings.tsx @@ -17,6 +17,7 @@ const mapDispatchToProps = (dispatch: any) => ({ brandDisplaySetting: TenantSettingBrandDisplay, locale: string, rootBoardId: number, + customDomain: string, showRoadmapInHeader: boolean, collapseBoardsInHeader: TenantSettingCollapseBoardsInHeader, showVoteCount: boolean, @@ -37,6 +38,7 @@ const mapDispatchToProps = (dispatch: any) => ({ collapse_boards_in_header: collapseBoardsInHeader, }, locale, + customDomain, authenticityToken, })); }, diff --git a/app/javascript/interfaces/ITenant.ts b/app/javascript/interfaces/ITenant.ts index 9ddf6b11..441d97fe 100644 --- a/app/javascript/interfaces/ITenant.ts +++ b/app/javascript/interfaces/ITenant.ts @@ -3,6 +3,7 @@ interface ITenant { siteName: string; siteLogo: string; locale: string; + customDomain?: string; } export default ITenant; \ No newline at end of file diff --git a/app/javascript/interfaces/json/ITenant.ts b/app/javascript/interfaces/json/ITenant.ts index d39dd2ba..8501feaf 100644 --- a/app/javascript/interfaces/json/ITenant.ts +++ b/app/javascript/interfaces/json/ITenant.ts @@ -4,6 +4,7 @@ interface ITenantJSON { site_logo: string; brand_display_setting: string; locale: string; + custom_domain?: string; } export default ITenantJSON; \ No newline at end of file diff --git a/app/models/o_auth.rb b/app/models/o_auth.rb index dc05a9e1..cfb969e1 100644 --- a/app/models/o_auth.rb +++ b/app/models/o_auth.rb @@ -27,9 +27,9 @@ class OAuth < ApplicationRecord # for this reason, we don't preprend tenant subdomain # but rather use the "login" subdomain 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 - 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 diff --git a/app/models/tenant.rb b/app/models/tenant.rb index 41eeecd3..b1ee7d4b 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -19,6 +19,7 @@ class Tenant < ApplicationRecord validates :site_name, presence: true validates :subdomain, presence: true, uniqueness: true 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 diff --git a/app/policies/tenant_policy.rb b/app/policies/tenant_policy.rb index 9a3b81a0..98faef4f 100644 --- a/app/policies/tenant_policy.rb +++ b/app/policies/tenant_policy.rb @@ -5,7 +5,7 @@ class TenantPolicy < ApplicationPolicy def permitted_attributes_for_update if user.admin? - [:site_name, :site_logo, :locale] + [:site_name, :site_logo, :locale, :custom_domain] else [] end diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb index bc93448a..0ff0fc36 100644 --- a/app/views/devise/mailer/confirmation_instructions.html.erb +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -4,6 +4,6 @@

<%= 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 }) %>

diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 8cfed221..915abe60 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -4,7 +4,7 @@

<%= 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 }) %>

diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb index fa9d5747..8c554264 100644 --- a/app/views/devise/mailer/unlock_instructions.html.erb +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -6,6 +6,6 @@

<%= 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 }) %>

diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 4e427e0b..27a15eca 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -5,7 +5,7 @@
- + <%= image_tag('https://raw.githubusercontent.com/astuto/astuto-assets/main/logo-64.png', alt: 'Astuto Logo', size: 64) %>
diff --git a/app/views/site_settings/general.html.erb b/app/views/site_settings/general.html.erb index 72a1fbd4..b0340073 100644 --- a/app/views/site_settings/general.html.erb +++ b/app/views/site_settings/general.html.erb @@ -13,6 +13,7 @@ showVoteButtonInBoard: @tenant_setting.show_vote_button_in_board, showPoweredBy: @tenant_setting.show_powered_by, rootBoardId: @tenant_setting.root_board_id.to_s, + customDomain: @tenant.custom_domain, showRoadmapInHeader: @tenant_setting.show_roadmap_in_header, collapseBoardsInHeader: @tenant_setting.collapse_boards_in_header, locale: @tenant.locale diff --git a/app/views/user_mailer/_unsubscribe_from_post.html.erb b/app/views/user_mailer/_unsubscribe_from_post.html.erb index 5a52cf94..c3cf9880 100644 --- a/app/views/user_mailer/_unsubscribe_from_post.html.erb +++ b/app/views/user_mailer/_unsubscribe_from_post.html.erb @@ -4,7 +4,7 @@ <%= t( '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)) ) %>

diff --git a/app/views/user_mailer/_unsubscribe_from_site.html.erb b/app/views/user_mailer/_unsubscribe_from_site.html.erb index 50d25504..9fa2d74b 100644 --- a/app/views/user_mailer/_unsubscribe_from_site.html.erb +++ b/app/views/user_mailer/_unsubscribe_from_site.html.erb @@ -4,7 +4,7 @@ <%= t( '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 %>

diff --git a/app/views/user_mailer/notify_comment_owner.html.erb b/app/views/user_mailer/notify_comment_owner.html.erb index 5e391ac3..6d7884be 100644 --- a/app/views/user_mailer/notify_comment_owner.html.erb +++ b/app/views/user_mailer/notify_comment_owner.html.erb @@ -7,7 +7,7 @@ <%= render 'user_mailer/quoted_text', text: @comment.body %>

- <%= 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) %>

<%= render 'user_mailer/closing' %> diff --git a/app/views/user_mailer/notify_follower_of_post_status_change.html.erb b/app/views/user_mailer/notify_follower_of_post_status_change.html.erb index 1b109144..cba7ea3e 100644 --- a/app/views/user_mailer/notify_follower_of_post_status_change.html.erb +++ b/app/views/user_mailer/notify_follower_of_post_status_change.html.erb @@ -9,7 +9,7 @@

- <%= 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) %>

<%= render 'user_mailer/closing' %> diff --git a/app/views/user_mailer/notify_follower_of_post_update.html.erb b/app/views/user_mailer/notify_follower_of_post_update.html.erb index 30027911..9af3d499 100644 --- a/app/views/user_mailer/notify_follower_of_post_update.html.erb +++ b/app/views/user_mailer/notify_follower_of_post_update.html.erb @@ -7,7 +7,7 @@ <%= render 'user_mailer/quoted_text', text: @comment.body %>

- <%= 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) %>

<%= render 'user_mailer/closing' %> diff --git a/app/views/user_mailer/notify_post_owner.html.erb b/app/views/user_mailer/notify_post_owner.html.erb index a2481d53..cdfc72fa 100644 --- a/app/views/user_mailer/notify_post_owner.html.erb +++ b/app/views/user_mailer/notify_post_owner.html.erb @@ -7,7 +7,7 @@ <%= render 'user_mailer/quoted_text', text: @comment.body %>

- <%= 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) %>

<%= render 'user_mailer/closing' %> diff --git a/app/workflows/add_custom_domain_workflow.rb b/app/workflows/add_custom_domain_workflow.rb new file mode 100644 index 00000000..a4cf2922 --- /dev/null +++ b/app/workflows/add_custom_domain_workflow.rb @@ -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 \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index b9a94f4a..7fefa969 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,6 +3,8 @@ Rails.application.configure do config.hosts << ".localhost:3000" 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 config.action_dispatch.tld_length = 0 diff --git a/config/locales/backend/backend.en.yml b/config/locales/backend/backend.en.yml index aaf68fed..347d9d7b 100644 --- a/config/locales/backend/backend.en.yml +++ b/config/locales/backend/backend.en.yml @@ -116,6 +116,7 @@ en: site_logo: 'Site logo' subdomain: 'Subdomain' locale: 'Language' + custom_domain: 'Custom domain' tenant_setting: brand_display: 'Display' show_vote_count: 'Show vote count to users' diff --git a/config/locales/en.yml b/config/locales/en.yml index c492f477..0b4ab362 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -158,6 +158,8 @@ en: collapse_boards_in_header_no_collapse: 'Never' collapse_boards_in_header_always_collapse: 'Always' 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_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: diff --git a/db/migrate/20240321171022_change_tenant_custom_url_column.rb b/db/migrate/20240321171022_change_tenant_custom_url_column.rb new file mode 100644 index 00000000..f567e285 --- /dev/null +++ b/db/migrate/20240321171022_change_tenant_custom_url_column.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index d9915808..56a2c401 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "plpgsql" @@ -153,10 +153,11 @@ ActiveRecord::Schema.define(version: 2024_03_03_103945) do t.string "site_logo" t.string "subdomain", null: false t.string "locale", default: "en" - t.string "custom_url" + t.string "custom_domain" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.integer "status" + t.index ["custom_domain"], name: "index_tenants_on_custom_domain", unique: true end create_table "users", force: :cascade do |t| diff --git a/spec/factories/tenants.rb b/spec/factories/tenants.rb index 91c39917..9440a4f8 100644 --- a/spec/factories/tenants.rb +++ b/spec/factories/tenants.rb @@ -4,6 +4,7 @@ FactoryBot.define do site_logo { "" } sequence(:subdomain) { |n| "mysubdomain#{n}" } locale { "en" } - custom_url { "" } + custom_domain { nil } + status { "active" } end end diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb index bf79994c..b460c2b1 100644 --- a/spec/models/tenant_spec.rb +++ b/spec/models/tenant_spec.rb @@ -35,4 +35,38 @@ RSpec.describe Tenant, type: :model do tenant.subdomain = tenant2.subdomain expect(tenant).to be_invalid 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