FEATURE: Allow using invites when DiscourseConnect SSO is enabled (#12419)

FEATURE: Allow using invites when DiscourseConnect SSO is enabled (#12419)

This PR allows invitations to be used when the DiscourseConnect SSO is enabled for a site (enable_discourse_connect) and local logins are disabled. Previously invites could not be accepted with SSO enabled simply because we did not have the code paths to handle that logic.

The invitation methods that are supported include:

  • Inviting people to groups via email address
  • Inviting people to topics via email address
  • Using invitation links generated by the Invite Users UI in the /my/invited/pending route

The flow works like this:

  1. User visits an invite URL
  2. The normal invitation validations (redemptions/expiry) happen at that point
  3. We store the invite key in a secure session
  4. The user clicks “Accept Invitation and Continue” (see below)
  5. The user is redirected to /session/sso then to the SSO provider URL then back to /session/sso_login
  6. We retrieve the invite based on the invite key in secure session. We revalidate the invitation. We show an error to the user if it is not valid. An additional check here for invites with an email specified is to check the SSO email matches the invite email
  7. If the invite is OK we create the user via the normal SSO methods
  8. We redeem the invite and activate the user. We clear the invite key in secure session.
  9. If the invite had a topic we redirect the user there, otherwise we redirect to /

Note that we decided for SSO-based invites the must_approve_users site setting is ignored, because the invite is a form of pre-approval, and because regular non-staff users cannot send out email invites or generally invite to the forum in this case.

Also deletes some group invite checks as per FIX: Allow group invites when local login is disabled. by tgxworld · Pull Request #12353 · discourse/discourse · GitHub

diff --git a/app/assets/javascripts/discourse/app/controllers/invites-show.js b/app/assets/javascripts/discourse/app/controllers/invites-show.js
index 875820d..0e640be 100644
--- a/app/assets/javascripts/discourse/app/controllers/invites-show.js
+++ b/app/assets/javascripts/discourse/app/controllers/invites-show.js
@@ -64,6 +64,11 @@ export default Controller.extend(
     },
 
     @discourseComputed
+    discourseConnectEnabled() {
+      return this.siteSettings.enable_discourse_connect;
+    },
+
+    @discourseComputed
     welcomeTitle() {
       return I18n.t("invites.welcome_to", {
         site_name: this.siteSettings.title,
@@ -83,10 +88,17 @@ export default Controller.extend(
     @discourseComputed
     externalAuthsOnly() {
       return (
-        !this.siteSettings.enable_local_logins && this.externalAuthsEnabled
+        !this.siteSettings.enable_local_logins &&
+        this.externalAuthsEnabled &&
+        !this.siteSettings.enable_discourse_connect
       );
     },
 
+    @discourseComputed("externalAuthsOnly", "discourseConnectEnabled")
+    showSocialLoginAvailable(externalAuthsOnly, discourseConnectEnabled) {
+      return !externalAuthsOnly && !discourseConnectEnabled;
+    },
+
     @discourseComputed(
       "externalAuthsOnly",
       "authOptions",
@@ -170,6 +182,9 @@ export default Controller.extend(
     @discourseComputed
     wavingHandURL: () => wavingHandURL(),
 
+    @discourseComputed
+    ssoPath: () => getUrl("/session/sso"),
+
     actions: {
       submit() {
         const userFields = this.userFields;
diff --git a/app/assets/javascripts/discourse/app/templates/invites/show.hbs b/app/assets/javascripts/discourse/app/templates/invites/show.hbs
index d4a0f5a..9a45776 100644
--- a/app/assets/javascripts/discourse/app/templates/invites/show.hbs
+++ b/app/assets/javascripts/discourse/app/templates/invites/show.hbs
@@ -21,15 +21,16 @@
         <p>{{user-info user=invitedBy}}</p>
 
         {{#unless isInviteLink}}
-          <p>
+          <p class="email-message">
             {{html-safe yourEmailMessage}}
-            {{#unless externalAuthsOnly}}
+            {{#if showSocialLoginAvailable}}
               {{i18n "invites.social_login_available"}}
-            {{/unless}}
+            {{/if}}
           </p>
         {{/unless}}
 
         {{#if externalAuthsOnly}}
+          {{! authOptions are present once the user has followed the OmniAuth flow (e.g. twitter/google/etc) }}
           {{#if authOptions}}
             {{#unless isInviteLink}}
               {{input-tip validation=emailValidation id="account-email-validation"}}
@@ -39,6 +40,12 @@
           {{/if}}
         {{/if}}
 
+        {{#if discourseConnectEnabled}}
+          <a class="btn btn-primary discourse-connect raw-link" href={{ssoPath}}>
+            {{i18n "continue"}}
+          </a>
+        {{/if}}
+
         {{#if shouldDisplayForm}}
           <form>
             {{#if isInviteLink}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js b/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js
index 1265053..472e9cb 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/invite-accept-test.js
@@ -170,6 +170,53 @@ acceptance("Invite accept when local login is disabled", function (needs) {
   });
 });
 
+acceptance(
+  "Invite accept when DiscourseConnect SSO is enabled and local login is disabled",
+  function (needs) {
+    needs.settings({
+      enable_local_logins: false,
+      enable_discourse_connect: true,
+    });
+
+    test("invite link", async function (assert) {
+      preloadInvite({ link: true });
+
+      await visit("/invites/myvalidinvitetoken");
+
+      assert.ok(
+        !exists(".btn-social.facebook"),
+        "does not show Facebook login button"
+      );
+      assert.ok(!exists("form"), "does not display the form");
+      assert.ok(
+        !exists(".email-message"),
+        "does not show the email message with the prefilled email"
+      );
+      assert.ok(exists(".discourse-connect"), "shows the Continue button");
+    });
+
+    test("email invite link", async function (assert) {
+      preloadInvite();
+
+      await visit("/invites/myvalidinvitetoken");
+
+      assert.ok(
+        !exists(".btn-social.facebook"),
+        "does not show Facebook login button"
+      );
+      assert.ok(!exists("form"), "does not display the form");
+      assert.ok(
+        exists(".email-message"),
+        "shows the email message with the prefilled email"
+      );
+      assert.ok(exists(".discourse-connect"), "shows the Continue button");
+      assert.ok(
+        queryAll(".email-message").text().includes("foobar@example.com")
+      );
+    });
+  }
+);
+
 acceptance("Invite link with authentication data", function (needs) {
   needs.settings({ enable_local_logins: false });
 
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 8d2beca..0d39f9a 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -336,14 +336,6 @@ class GroupsController < ApplicationController
       raise Discourse::InvalidParameters.new(I18n.t("groups.errors.usernames_or_emails_required"))
     end
 
-    if emails.any?
-      if SiteSetting.enable_discourse_connect?
-        raise Discourse::InvalidParameters.new(I18n.t("groups.errors.no_invites_with_discourse_connect"))
-      elsif !SiteSetting.enable_local_logins?
-        raise Discourse::InvalidParameters.new(I18n.t("groups.errors.no_invites_without_local_logins"))
-      end
-    end
-
     if users.length > ADD_MEMBERS_LIMIT
       return render_json_error(
         I18n.t("groups.errors.adding_too_many_users", count: ADD_MEMBERS_LIMIT)
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 8843efe..98210b7 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -16,7 +16,7 @@ class InvitesController < ApplicationController
     expires_now
 
     invite = Invite.find_by(invite_key: params[:id])
-    if invite.present? && !invite.expired? && !invite.redeemed?
+    if invite.present? && invite.redeemable?
       email = Email.obfuscate(invite.email)
 
       # Show email if the user already authenticated their email
@@ -34,14 +34,16 @@ class InvitesController < ApplicationController
         is_invite_link: invite.is_invite_link?
       ))
 
+      secure_session["invite-key"] = invite.invite_key
+
       render layout: 'application'
     else
-      flash.now[:error] = if invite&.expired?
-        I18n.t('invite.expired', base_url: Discourse.base_url)
-      elsif invite&.redeemed?
-        I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)
-      else
+      flash.now[:error] = if invite.blank?
         I18n.t('invite.not_found', base_url: Discourse.base_url)
+      elsif invite.redeemed?
+        I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)
+      elsif invite.expired?
+        I18n.t('invite.expired', base_url: Discourse.base_url)
       end
 
       render layout: 'no_ember'
@@ -165,6 +167,8 @@ class InvitesController < ApplicationController
     render json: success_json
   end
 
+  # For DiscourseConnect SSO, all invite acceptance is done
+  # via the SessionController#sso_login route
   def perform_accept_invitation
     params.require(:id)
     params.permit(:email, :username, :name, :password, :timezone, user_custom_fields: {})
@@ -190,7 +194,7 @@ class InvitesController < ApplicationController
             invite.email
           end
 
-        user = invite.redeem(attrs)
+        user = invite.redeem(**attrs)
       rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e

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

GitHub sha: 355d51af

This commit appears in #12419 which was approved by tgxworld. It was merged by martin.

This commit has been mentioned on Discourse Meta. There might be relevant details there: