FEATURE: External auth when redeeming invites

FEATURE: External auth when redeeming invites

This feature (when enabled) will allow for invite_only sites to require external authentication before they can redeem an invite.

  • Created hidden site setting to toggle this
  • Enables sending invites with local logins disabled
  • OAuth button added to invite form
  • Requires OAuth email address to match invite email address
  • Prevents redeeming invite if OAuth authentication fails
diff --git a/app/assets/javascripts/discourse/controllers/invites-show.js.es6 b/app/assets/javascripts/discourse/controllers/invites-show.js.es6
index 2664a95..701a757 100644
--- a/app/assets/javascripts/discourse/controllers/invites-show.js.es6
+++ b/app/assets/javascripts/discourse/controllers/invites-show.js.es6
@@ -5,6 +5,7 @@ import { ajax } from "discourse/lib/ajax";
 import PasswordValidation from "discourse/mixins/password-validation";
 import UsernameValidation from "discourse/mixins/username-validation";
 import NameValidation from "discourse/mixins/name-validation";
+import InviteEmailAuthValidation from "discourse/mixins/invite-email-auth-validation";
 import UserFieldsValidation from "discourse/mixins/user-fields-validation";
 import { findAll as findLoginMethods } from "discourse/models/login-method";
 
@@ -12,8 +13,11 @@ export default Ember.Controller.extend(
   PasswordValidation,
   UsernameValidation,
   NameValidation,
+  InviteEmailAuthValidation,
   UserFieldsValidation,
   {
+    login: Ember.inject.controller(),
+
     invitedBy: Ember.computed.alias("model.invited_by"),
     email: Ember.computed.alias("model.email"),
     accountUsername: Ember.computed.alias("model.username"),
@@ -22,6 +26,7 @@ export default Ember.Controller.extend(
     errorMessage: null,
     userFields: null,
     inviteImageUrl: getUrl("/images/envelope.svg"),
+    hasAuthOptions: Ember.computed.notEmpty("authOptions"),
 
     @computed
     welcomeTitle() {
@@ -35,24 +40,40 @@ export default Ember.Controller.extend(
       return I18n.t("invites.your_email", { email: email });
     },
 
+    authProviderDisplayName(providerName) {
+      const matchingProvider = findLoginMethods().find(provider => {
+        return provider.name === providerName;
+      });
+      return matchingProvider
+        ? matchingProvider.get("prettyName")
+        : providerName;
+    },
+
     @computed
     externalAuthsEnabled() {
       return findLoginMethods().length > 0;
     },
 
+    @computed
+    inviteOnlyOauthEnabled() {
+      return this.siteSettings.enable_invite_only_oauth;
+    },
+
     @computed(
       "usernameValidation.failed",
       "passwordValidation.failed",
       "nameValidation.failed",
-      "userFieldsValidation.failed"
+      "userFieldsValidation.failed",
+      "inviteEmailAuthValidation.failed",
     )
     submitDisabled(
       usernameFailed,
       passwordFailed,
       nameFailed,
-      userFieldsFailed
+      userFieldsFailed,
+      inviteEmailAuthFailed,
     ) {
-      return usernameFailed || passwordFailed || nameFailed || userFieldsFailed;
+      return usernameFailed || passwordFailed || nameFailed || userFieldsFailed || inviteEmailAuthFailed;
     },
 
     @computed
@@ -63,6 +84,10 @@ export default Ember.Controller.extend(
     },
 
     actions: {
+      externalLogin(provider) {
+        this.login.send("externalLogin", provider);
+      },
+
       submit() {
         const userFields = this.userFields;
         let userCustomFields = {};
diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6
index 19da455..76f33c9 100644
--- a/app/assets/javascripts/discourse/controllers/login.js.es6
+++ b/app/assets/javascripts/discourse/controllers/login.js.es6
@@ -20,6 +20,7 @@ const AuthErrors = [
 
 export default Ember.Controller.extend(ModalFunctionality, {
   createAccount: Ember.inject.controller(),
+  invitesShow: Ember.inject.controller(),
   forgotPassword: Ember.inject.controller(),
   application: Ember.inject.controller(),
 
@@ -353,14 +354,23 @@ export default Ember.Controller.extend(ModalFunctionality, {
       return;
     }
 
-    const createAccountController = this.createAccount;
-    createAccountController.setProperties({
-      accountEmail: options.email,
-      accountUsername: options.username,
-      accountName: options.name,
-      authOptions: Ember.Object.create(options)
-    });
-
-    showModal("createAccount");
+    if (this.siteSettings.enable_invite_only_oauth) {
+      const invitesShowController = this.invitesShow;
+      invitesShowController.setProperties({
+        accountEmail: options.email,
+        accountUsername: options.username,
+        accountName: options.name,
+        authOptions: Ember.Object.create(options)
+      });
+    } else {
+      const createAccountController = this.createAccount;
+      createAccountController.setProperties({
+        accountEmail: options.email,
+        accountUsername: options.username,
+        accountName: options.name,
+        authOptions: Ember.Object.create(options)
+      });
+      showModal("createAccount");
+    }
   }
 });
diff --git a/app/assets/javascripts/discourse/mixins/invite-email-auth-validation.js.es6 b/app/assets/javascripts/discourse/mixins/invite-email-auth-validation.js.es6
new file mode 100644
index 0000000..733573d
--- /dev/null
+++ b/app/assets/javascripts/discourse/mixins/invite-email-auth-validation.js.es6
@@ -0,0 +1,38 @@
+import InputValidation from "discourse/models/input-validation";
+import { default as computed } from "ember-addons/ember-computed-decorators";
+
+export default Ember.Mixin.create({
+  @computed()
+  nameInstructions() {
+    "";
+  },
+
+  // Validate the name.
+  @computed("accountEmail", "authOptions.email", "authOptions.email_valid", "authOptions.auth_provider")
+  inviteEmailAuthValidation() {
+    if (
+      !this.siteSettings.enable_invite_only_oauth ||
+      (this.siteSettings.enable_invite_only_oauth &&
+      this.get("authOptions.email") === this.email &&
+      this.get("authOptions.email_valid"))
+    ) {
+      return InputValidation.create({
+        ok: true,
+        reason: I18n.t("user.email.authenticated", {
+          provider: this.authProviderDisplayName(
+            this.get("authOptions.auth_provider")
+          )
+        })
+      });
+    }
+
+    return InputValidation.create({
+      failed: true,
+      reason: I18n.t("user.email.invite_email_auth_invalid", {
+        provider: this.authProviderDisplayName(
+          this.get("authOptions.auth_provider")
+        )
+      })
+    });
+  }
+});
diff --git a/app/assets/javascripts/discourse/templates/invites/show.hbs b/app/assets/javascripts/discourse/templates/invites/show.hbs
index 6b3b461..467d918 100644
--- a/app/assets/javascripts/discourse/templates/invites/show.hbs
+++ b/app/assets/javascripts/discourse/templates/invites/show.hbs
@@ -14,55 +14,98 @@
       {{else}}
         <p>{{i18n 'invites.invited_by'}}</p>
         <p>{{user-info user=invitedBy}}</p>
-
-        <p>{{{yourEmailMessage}}}
+        <p>
+        {{{yourEmailMessage}}}
+        {{#if inviteOnlyOauthEnabled }}
+          {{login-buttons externalLogin=(action "externalLogin")}}
+        {{/if}}
         {{#if externalAuthsEnabled}}
-             {{i18n 'invites.social_login_available'}}
+          {{#unless inviteOnlyOauthEnabled}}
+            {{i18n 'invites.social_login_available'}}
+          {{/unless}}
         {{/if}}
         </p>
 
-        <form>
-          <div class="input username-input">
-            <label>{{i18n 'user.username.title'}}</label>
-            {{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}}
-            &nbsp;{{input-tip validation=usernameValidation id="username-validation"}}
-            <div class="instructions">{{i18n 'user.username.instructions'}}</div>
-          </div>
-
-          {{#if fullnameRequired}}
-            <div class="input name-input">
-              <label>{{i18n 'invites.name_label'}}</label>
-              {{input value=accountName id="new-account-name" name="name"}}
-              <div class="instructions">{{nameInstructions}}</div>
-            </div>
+        {{#if hasAuthOptions}}
+          {{#if inviteOnlyOauthEnabled }}
+            {{input-tip validation=inviteEmailAuthValidation id="account-email-validation"}}
           {{/if}}
+          <form>

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

GitHub sha: 87a0a666

1 Like

Small tip, you can shorten this to

const matchingProvider = findLoginMethods().find(p => p.name === providerName)
1 Like

This whole computed property can be replaced with the Ember.computed.or “alias”

submitDisabled: Ember.computed.or("usernameValidation.failed", "passwordValidation.failed", ...)
1 Like

Any reasons why you’re declaring this variable? Seems like it’s only being used the line below?

1 Like

Ditto ^^

1 Like

I find this quite hard to read. Any way we could extract part of this condition into a temporary (but adequately named) variable?

1 Like

Same as on the client-side. Can we either extract part of the condition into temporary variables or use “return early” to ease the understanding of that method?

1 Like

This is odd because the empty string is not returned.

2 Likes

Ohh yea, I forgot I did that and then moved on to bigger problems. I’ll clean that up.

1 Like

REVERT: External auth when redeeming invites