FEATURE: Auto-activate users invited by email (#12675)

FEATURE: Auto-activate users invited by email (#12675)

When invited by email, users will receive an invite URL which contains a token. If that token is present when the invite is redeemed, their account will be automatically activated.

diff --git a/app/assets/javascripts/discourse/app/controllers/invites-show.js b/app/assets/javascripts/discourse/app/controllers/invites-show.js
index f7cf803..3c3658b 100644
--- a/app/assets/javascripts/discourse/app/controllers/invites-show.js
+++ b/app/assets/javascripts/discourse/app/controllers/invites-show.js
@@ -22,6 +22,8 @@ export default Controller.extend(
   NameValidation,
   UserFieldsValidation,
   {
+    queryParams: ["t"],
+
     createAccount: controller(),
 
     invitedBy: readOnly("model.invited_by"),
@@ -216,6 +218,8 @@ export default Controller.extend(
 
         if (this.isInviteLink) {
           data.email = this.email;
+        } else {
+          data.email_token = this.t;
         }
 
         ajax({
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 3cbf6fc..1a86eb6 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -197,7 +197,7 @@ class InvitesController < ApplicationController
   # via the SessionController#sso_login route
   def perform_accept_invitation
     params.require(:id)
-    params.permit(:email, :username, :name, :password, :timezone, user_custom_fields: {})
+    params.permit(:email, :username, :name, :password, :timezone, :email_token, user_custom_fields: {})
 
     invite = Invite.find_by(invite_key: params[:id])
 
@@ -212,13 +212,13 @@ class InvitesController < ApplicationController
           session: session
         }
 
-        attrs[:email] =
-          if invite.is_invite_link?
-            params.require([:email])
-            params[:email]
-          else
-            invite.email
-          end
+        if invite.is_invite_link?
+          params.require(:email)
+          attrs[:email] = params[:email]
+        else
+          attrs[:email] = invite.email
+          attrs[:email_token] = params[:email_token] if params[:email_token].present?
+        end
 
         user = invite.redeem(**attrs)
       rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
diff --git a/app/mailers/invite_mailer.rb b/app/mailers/invite_mailer.rb
index 2f751e3..6f5f284 100644
--- a/app/mailers/invite_mailer.rb
+++ b/app/mailers/invite_mailer.rb
@@ -36,7 +36,7 @@ class InviteMailer < ActionMailer::Base
                   template: sanitized_message ? 'custom_invite_mailer' : 'invite_mailer',
                   inviter_name: inviter_name,
                   site_domain_name: Discourse.current_hostname,
-                  invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}",
+                  invite_link: invite.link(with_email_token: true),
                   topic_title: topic_title,
                   topic_excerpt: topic_excerpt,
                   site_description: SiteSetting.site_description,
@@ -47,7 +47,7 @@ class InviteMailer < ActionMailer::Base
                   template: sanitized_message ? 'custom_invite_forum_mailer' : 'invite_forum_mailer',
                   inviter_name: inviter_name,
                   site_domain_name: Discourse.current_hostname,
-                  invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}",
+                  invite_link: invite.link(with_email_token: true),
                   site_description: SiteSetting.site_description,
                   site_title: SiteSetting.title,
                   user_custom_message: sanitized_message)
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 109b767..7618163 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -39,6 +39,12 @@ class Invite < ActiveRecord::Base
     self.expires_at ||= SiteSetting.invite_expiry_days.days.from_now
   end
 
+  before_save do
+    if will_save_change_to_email?
+      self.email_token = email.present? ? SecureRandom.hex : nil
+    end
+  end
+
   before_validation do
     self.email = Email.downcase(email) unless email.nil?
   end
@@ -85,8 +91,9 @@ class Invite < ActiveRecord::Base
     expires_at < Time.zone.now
   end
 
-  def link
-    "#{Discourse.base_url}/invites/#{invite_key}"
+  def link(with_email_token: false)
+    with_email_token ? "#{Discourse.base_url}/invites/#{invite_key}?t=#{email_token}"
+                     : "#{Discourse.base_url}/invites/#{invite_key}"
   end
 
   def link_valid?
@@ -167,7 +174,7 @@ class Invite < ActiveRecord::Base
     invite.reload
   end
 
-  def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil)
+  def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil, email_token: nil)
     return if !redeemable?
 
     if is_invite_link? && UserEmail.exists?(email: email)
@@ -183,7 +190,8 @@ class Invite < ActiveRecord::Base
       password: password,
       user_custom_fields: user_custom_fields,
       ip_address: ip_address,
-      session: session
+      session: session,
+      email_token: email_token
     ).redeem
   end
 
diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb
index 8c7b4aa..f8c607a 100644
--- a/app/models/invite_redeemer.rb
+++ b/app/models/invite_redeemer.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_custom_fields, :ip_address, :session, keyword_init: true) do
+InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_custom_fields, :ip_address, :session, :email_token, keyword_init: true) do
 
   def redeem
     Invite.transaction do
@@ -14,7 +14,7 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
   end
 
   # extracted from User cause it is very specific to invites
-  def self.create_user_from_invite(email:, invite:, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil)
+  def self.create_user_from_invite(email:, invite:, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil, session: nil, email_token: nil)
     user = User.where(staged: true).with_email(email.strip.downcase).first
     user.unstage! if user
 
@@ -76,7 +76,7 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
     user.save!
     authenticator.finish
 
-    if invite.emailed_status != Invite.emailed_status_types[:not_required] && email == invite.email
+    if invite.emailed_status != Invite.emailed_status_types[:not_required] && email == invite.email && invite.email_token.present? && email_token == invite.email_token
       user.email_tokens.create!(email: user.email)
       user.activate
     end
@@ -131,7 +131,8 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_
       password: password,
       user_custom_fields: user_custom_fields,
       ip_address: ip_address,
-      session: session
+      session: session,
+      email_token: email_token
     )
     result.send_welcome_message = false
     result
diff --git a/db/migrate/20210409142455_add_token_to_invites.rb b/db/migrate/20210409142455_add_token_to_invites.rb
new file mode 100644
index 0000000..43d1632
--- /dev/null
+++ b/db/migrate/20210409142455_add_token_to_invites.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddTokenToInvites < ActiveRecord::Migration[6.0]
+  def change
+    add_column :invites, :email_token, :string
+  end
+end
diff --git a/plugins/discourse-narrative-bot/spec/requests/discobot_welcome_post_spec.rb b/plugins/discourse-narrative-bot/spec/requests/discobot_welcome_post_spec.rb
index 334d16c..015879c 100644
--- a/plugins/discourse-narrative-bot/spec/requests/discobot_welcome_post_spec.rb
+++ b/plugins/discourse-narrative-bot/spec/requests/discobot_welcome_post_spec.rb
@@ -29,16 +29,15 @@ describe "Discobot welcome post" do
     end
 
     context 'when user redeems an invite' do
-      let(:invite) { Fabricate(:invite, invited_by: Fabricate(:admin), email: 'testing@gmail.com') }

[... diff too long, it was truncated ...]

GitHub sha: 528cfea0

This commit appears in #12675 which was approved by ZogStriP. It was merged by udan11.