diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js
index 2a4cb7e8412..8494aad52f0 100644
--- a/app/assets/javascripts/discourse/app/controllers/create-invite.js
+++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js
@@ -1,9 +1,10 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
-import { empty, notEmpty } from "@ember/object/computed";
+import { not } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators";
import { extractError } from "discourse/lib/ajax-error";
import { getNativeContact } from "discourse/lib/pwa-utils";
+import { emailValid, hostnameValid } from "discourse/lib/utilities";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import Group from "discourse/models/group";
@@ -28,8 +29,17 @@ export default Controller.extend(
inviteToTopic: false,
limitToEmail: false,
- isLink: empty("buffered.email"),
- isEmail: notEmpty("buffered.email"),
+ @discourseComputed("buffered.emailOrDomain")
+ isEmail(emailOrDomain) {
+ return emailValid(emailOrDomain);
+ },
+
+ @discourseComputed("buffered.emailOrDomain")
+ isDomain(emailOrDomain) {
+ return hostnameValid(emailOrDomain);
+ },
+
+ isLink: not("isEmail"),
onShow() {
Group.findAll().then((groups) => {
@@ -67,6 +77,15 @@ export default Controller.extend(
save(opts) {
const data = { ...this.buffered.buffer };
+ if (data.emailOrDomain) {
+ if (emailValid(data.emailOrDomain)) {
+ data.email = data.emailOrDomain;
+ } else if (hostnameValid(data.emailOrDomain)) {
+ data.domain = data.emailOrDomain;
+ }
+ delete data.emailOrDomain;
+ }
+
if (data.groupIds !== undefined) {
data.group_ids = data.groupIds.length > 0 ? data.groupIds : "";
delete data.groupIds;
diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js
index c6a5ca1446d..341512e4eba 100644
--- a/app/assets/javascripts/discourse/app/lib/utilities.js
+++ b/app/assets/javascripts/discourse/app/lib/utilities.js
@@ -142,6 +142,12 @@ export function emailValid(email) {
return re.test(email);
}
+export function hostnameValid(hostname) {
+ // see: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
+ const re = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
+ return hostname && re.test(hostname);
+}
+
export function extractDomainFromUrl(url) {
if (url.indexOf("://") > -1) {
url = url.split("/")[2];
diff --git a/app/assets/javascripts/discourse/app/models/invite.js b/app/assets/javascripts/discourse/app/models/invite.js
index 7ad702f223c..122208bc1b2 100644
--- a/app/assets/javascripts/discourse/app/models/invite.js
+++ b/app/assets/javascripts/discourse/app/models/invite.js
@@ -49,6 +49,11 @@ const Invite = EmberObject.extend({
return topicData ? Topic.create(topicData) : null;
},
+ @discourseComputed("email", "domain")
+ emailOrDomain(email, domain) {
+ return email || domain;
+ },
+
topicId: alias("topics.firstObject.id"),
topicTitle: alias("topics.firstObject.title"),
});
diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs
index d31844c4846..0fce75b6681 100644
--- a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs
+++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs
@@ -37,12 +37,21 @@
{{/if}}
-
+
{{input
id="invite-email"
- value=buffered.email
- placeholderKey="topic.invite_reply.email_placeholder"
+ value=buffered.emailOrDomain
+ placeholderKey="user.invited.invite.email_or_domain_placeholder"
}}
{{#if capabilities.hasContactPicker}}
{{d-button
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 1e1fd7c4311..408b4004d26 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -134,6 +134,7 @@ class InvitesController < ApplicationController
begin
invite = Invite.generate(current_user,
email: params[:email],
+ domain: params[:domain],
skip_email: params[:skip_email],
invited_by: current_user,
custom_message: params[:custom_message],
@@ -210,6 +211,17 @@ class InvitesController < ApplicationController
Invite.emailed_status_types[:not_required]
end
end
+
+ invite.domain = nil if invite.email.present?
+ end
+
+ if params.has_key?(:domain)
+ invite.domain = params[:domain]
+
+ if invite.domain.present?
+ invite.email = nil
+ invite.emailed_status = Invite.emailed_status_types[:not_required]
+ end
end
if params[:send_email]
diff --git a/app/models/invite.rb b/app/models/invite.rb
index a5c08d38347..77cc9fc79c2 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -15,6 +15,7 @@ class Invite < ActiveRecord::Base
}
BULK_INVITE_EMAIL_LIMIT = 200
+ HOSTNAME_REGEX = /\A(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])\z/
rate_limit :limit_invites_per_day
@@ -30,6 +31,7 @@ class Invite < ActiveRecord::Base
validates_presence_of :invited_by_id
validates :email, email: true, allow_blank: true
validate :ensure_max_redemptions_allowed
+ validate :valid_domain, if: :will_save_change_to_domain?
validate :user_doesnt_already_exist
before_create do
@@ -143,7 +145,7 @@ class Invite < ActiveRecord::Base
emailed_status: emailed_status
)
else
- create_args = opts.slice(:email, :moderator, :custom_message, :max_redemptions_allowed)
+ create_args = opts.slice(:email, :domain, :moderator, :custom_message, :max_redemptions_allowed)
create_args[:invited_by] = invited_by
create_args[:email] = email
create_args[:emailed_status] = emailed_status
@@ -236,12 +238,10 @@ class Invite < ActiveRecord::Base
end
def self.invalidate_for_email(email)
- i = Invite.find_by(email: Email.downcase(email))
- if i
- i.invalidated_at = Time.zone.now
- i.save
- end
- i
+ invite = Invite.find_by(email: Email.downcase(email))
+ invite.update!(invalidated_at: Time.zone.now) if invite
+
+ invite
end
def resend_invite
@@ -286,6 +286,16 @@ class Invite < ActiveRecord::Base
end
end
end
+
+ def valid_domain
+ return if self.domain.blank?
+
+ self.domain.downcase!
+
+ if self.domain !~ Invite::HOSTNAME_REGEX
+ self.errors.add(:base, I18n.t('invite.domain_not_allowed', domain: self.domain))
+ end
+ end
end
# == Schema Information
@@ -308,6 +318,7 @@ end
# redemption_count :integer default(0), not null
# expires_at :datetime not null
# email_token :string
+# domain :string
#
# Indexes
#
diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb
index 2ba2a6771b0..e8eec563b7c 100644
--- a/app/models/invite_redeemer.rb
+++ b/app/models/invite_redeemer.rb
@@ -19,6 +19,13 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
available_username = UserNameSuggester.suggest(email)
end
+ if email.present? && invite.domain.present?
+ username, domain = email.split('@')
+ if domain.present? && invite.domain != domain
+ raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed'))
+ end
+ end
+
user = User.where(staged: true).with_email(email.strip.downcase).first
user.unstage! if user
user ||= User.new
diff --git a/app/serializers/invite_serializer.rb b/app/serializers/invite_serializer.rb
index 51aa36939e3..f9f61aa70ea 100644
--- a/app/serializers/invite_serializer.rb
+++ b/app/serializers/invite_serializer.rb
@@ -5,6 +5,7 @@ class InviteSerializer < ApplicationSerializer
:invite_key,
:link,
:email,
+ :domain,
:emailed,
:max_redemptions_allowed,
:redemption_count,
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 02761797b7f..4c5c10671e8 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1621,7 +1621,10 @@ en:
show_advanced: "Show Advanced Options"
hide_advanced: "Hide Advanced Options"
+ restrict_email_or_domain: "Restrict to email or domain"
+ email_or_domain_placeholder: "name@example.com or example.com"
restrict_email: "Restrict to email"
+ restrict_domain: "Restrict to domain"
max_redemptions_allowed: "Max uses"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 1baf352b266..7aeb6d57e16 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -259,6 +259,7 @@ en:
discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled."
invalid_access: "You are not permitted to view the requested resource."
requires_groups: "Invite saved. To give access to the specified topic, add one of the following groups: %{groups}."
+ domain_not_allowed: "Your email cannot be used to redeem this invite."
bulk_invite:
file_should_be_csv: "The uploaded file should be of csv format."
diff --git a/db/migrate/20211207130646_add_domain_to_invites.rb b/db/migrate/20211207130646_add_domain_to_invites.rb
new file mode 100644
index 00000000000..1782efdf22a
--- /dev/null
+++ b/db/migrate/20211207130646_add_domain_to_invites.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddDomainToInvites < ActiveRecord::Migration[6.1]
+ def change
+ add_column :invites, :domain, :string
+ end
+end
diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb
index 4727df2dd13..8bd06700c4f 100644
--- a/spec/requests/invites_controller_spec.rb
+++ b/spec/requests/invites_controller_spec.rb
@@ -677,6 +677,31 @@ describe InvitesController do
end
end
+ context 'with a domain invite' do
+ fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required], domain: 'example.com') }
+
+ it 'creates an user if email matches domain' do
+ expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' } }
+ .to change { User.count }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body['message']).to eq(I18n.t('invite.confirm_email'))
+ expect(invite.reload.redemption_count).to eq(1)
+
+ invited_user = User.find_by_email('test@example.com')
+ expect(invited_user).to be_present
+ end
+
+ it 'does not create an user if email does not match domain' do
+ expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example2.com', password: 'verystrongpassword' } }
+ .not_to change { User.count }
+
+ expect(response.status).to eq(412)
+ expect(response.parsed_body['message']).to eq(I18n.t('invite.domain_not_allowed'))
+ expect(invite.reload.redemption_count).to eq(0)
+ end
+ end
+
context 'with an invite link' do
fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required]) }