FEATURE: Use second factor for admin confirmation (#14293)

FEATURE: Use second factor for admin confirmation (#14293)

Administrators can use second factor to confirm granting admin access without using email. The old method of confirmation via email is still used as a fallback when second factor is unavailable.

diff --git a/app/assets/javascripts/admin/addon/controllers/admin-user-index.js b/app/assets/javascripts/admin/addon/controllers/admin-user-index.js
index 93ed9c3..22345e8 100644
--- a/app/assets/javascripts/admin/addon/controllers/admin-user-index.js
+++ b/app/assets/javascripts/admin/addon/controllers/admin-user-index.js
@@ -218,8 +218,15 @@ export default Controller.extend(CanCheckEmails, {
     grantAdmin() {
       return this.model
         .grantAdmin()
-        .then(() => {
-          bootbox.alert(I18n.t("admin.user.grant_admin_confirm"));
+        .then((result) => {
+          if (result.email_confirmation_required) {
+            bootbox.alert(I18n.t("admin.user.grant_admin_confirm"));
+          } else {
+            const controller = showModal("grant-admin-second-factor", {
+              model: this.model,
+            });
+            controller.setResult(result);
+          }
         })
         .catch(popupAjaxError);
     },
diff --git a/app/assets/javascripts/admin/addon/models/admin-user.js b/app/assets/javascripts/admin/addon/models/admin-user.js
index cfd34b8..3fb3e28 100644
--- a/app/assets/javascripts/admin/addon/models/admin-user.js
+++ b/app/assets/javascripts/admin/addon/models/admin-user.js
@@ -99,9 +99,20 @@ const AdminUser = User.extend({
     });
   },
 
-  grantAdmin() {
+  grantAdmin(data) {
     return ajax(`/admin/users/${this.id}/grant_admin`, {
       type: "PUT",
+      data,
+    }).then((resp) => {
+      if (resp.success && !resp.email_confirmation_required) {
+        this.setProperties({
+          admin: true,
+          can_grant_admin: false,
+          can_revoke_admin: true,
+        });
+      }
+
+      return resp;
     });
   },
 
diff --git a/app/assets/javascripts/admin/addon/templates/user-index.hbs b/app/assets/javascripts/admin/addon/templates/user-index.hbs
index d81aeb2..df6c4c8 100644
--- a/app/assets/javascripts/admin/addon/templates/user-index.hbs
+++ b/app/assets/javascripts/admin/addon/templates/user-index.hbs
@@ -334,7 +334,7 @@
       {{/if}}
       {{#if model.can_grant_admin}}
         {{d-button
-          class="btn-default"
+          class="btn-default grant-admin"
           action=(action "grantAdmin")
           icon="shield-alt"
           label="admin.user.grant_admin"}}
diff --git a/app/assets/javascripts/discourse/app/controllers/grant-admin-second-factor.js b/app/assets/javascripts/discourse/app/controllers/grant-admin-second-factor.js
new file mode 100644
index 0000000..2debd00
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/controllers/grant-admin-second-factor.js
@@ -0,0 +1,83 @@
+import Controller from "@ember/controller";
+import { action } from "@ember/object";
+import discourseComputed from "discourse-common/utils/decorators";
+import { getWebauthnCredential } from "discourse/lib/webauthn";
+import ModalFunctionality from "discourse/mixins/modal-functionality";
+import { SECOND_FACTOR_METHODS } from "discourse/models/user";
+import I18n from "I18n";
+
+export default Controller.extend(ModalFunctionality, {
+  showSecondFactor: false,
+  secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
+  secondFactorToken: null,
+  securityKeyCredential: null,
+
+  inProgress: false,
+
+  onShow() {
+    this.setProperties({
+      showSecondFactor: false,
+      secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
+      secondFactorToken: null,
+      securityKeyCredential: null,
+    });
+  },
+
+  @discourseComputed("inProgress", "securityKeyCredential", "secondFactorToken")
+  disabled(inProgress, securityKeyCredential, secondFactorToken) {
+    return inProgress || (!securityKeyCredential && !secondFactorToken);
+  },
+
+  setResult(result) {
+    this.setProperties({
+      otherMethodAllowed: result.multiple_second_factor_methods,
+      secondFactorRequired: true,
+      showLoginButtons: false,
+      backupEnabled: result.backup_enabled,
+      showSecondFactor: result.totp_enabled,
+      showSecurityKey: result.security_key_enabled,
+      secondFactorMethod: result.security_key_enabled
+        ? SECOND_FACTOR_METHODS.SECURITY_KEY
+        : SECOND_FACTOR_METHODS.TOTP,
+      securityKeyChallenge: result.challenge,
+      securityKeyAllowedCredentialIds: result.allowed_credential_ids,
+    });
+  },
+
+  @action
+  authenticateSecurityKey() {
+    getWebauthnCredential(
+      this.securityKeyChallenge,
+      this.securityKeyAllowedCredentialIds,
+      (credentialData) => {
+        this.set("securityKeyCredential", credentialData);
+        this.send("authenticate");
+      },
+      (errorMessage) => {
+        this.flash(errorMessage, "error");
+      }
+    );
+  },
+
+  @action
+  authenticate() {
+    this.set("inProgress", true);
+    this.model
+      .grantAdmin({
+        second_factor_token:
+          this.securityKeyCredential || this.secondFactorToken,
+        second_factor_method: this.secondFactorMethod,
+        timezone: moment.tz.guess(),
+      })
+      .then((result) => {
+        if (result.success) {
+          this.send("closeModal");
+          bootbox.alert(I18n.t("admin.user.grant_admin_success"));
+        } else {
+          this.flash(result.error, "error");
+          this.setResult(result);
+        }
+      })
+      .finally(() => this.set("inProgress", false));
+  },
+});
diff --git a/app/assets/javascripts/discourse/app/templates/modal/grant-admin-second-factor.hbs b/app/assets/javascripts/discourse/app/templates/modal/grant-admin-second-factor.hbs
new file mode 100644
index 0000000..3c49f73
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/modal/grant-admin-second-factor.hbs
@@ -0,0 +1,33 @@
+{{#d-modal-body title="admin.user.grant_admin"}}
+  {{#second-factor-form
+    secondFactorMethod=secondFactorMethod
+    secondFactorToken=secondFactorToken
+    class=secondFactorClass
+    backupEnabled=backupEnabled
+  }}
+    {{#if showSecurityKey}}
+      {{#security-key-form
+        allowedCredentialIds=securityKeyAllowedCredentialIds
+        challenge=securityKeyChallenge
+        showSecurityKey=showSecurityKey
+        showSecondFactor=showSecondFactor
+        secondFactorMethod=secondFactorMethod
+        otherMethodAllowed=otherMethodAllowed
+        action=(action "authenticateSecurityKey")}}
+      {{/security-key-form}}
+    {{else}}
+      {{second-factor-input value=secondFactorToken inputId="second-factor-confirmation" secondFactorMethod=secondFactorMethod backupEnabled=backupEnabled}}
+    {{/if}}
+  {{/second-factor-form}}
+
+  {{#unless showSecurityKey}}
+    <div class="modal-footer">
+      {{d-button
+        action=(action "authenticate")
+        icon="shield-alt"
+        label="admin.user.grant_admin"
+        disabled=disabled
+        class="btn btn-primary"}}
+    </div>
+  {{/unless}}
+{{/d-modal-body}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js
index 3c399d2..c1403fa 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js
@@ -1,11 +1,13 @@
 import {
   acceptance,
   exists,
+  query,
   queryAll,
 } from "discourse/tests/helpers/qunit-helpers";
 import { click, currentURL, fillIn, visit } from "@ember/test-helpers";
 import selectKit from "discourse/tests/helpers/select-kit-helper";
 import { test } from "qunit";
+import I18n from "I18n";
 
 acceptance("Admin - User Index", function (needs) {
   needs.user();
@@ -40,6 +42,60 @@ acceptance("Admin - User Index", function (needs) {
     server.put("/users/sam/preferences/username", () => {
       return helper.response({ id: 2, username: "new-sam" });
     });
+
+    server.get("/admin/users/3.json", () => {
+      return helper.response({
+        id: 3,
+        username: "user1",
+        name: null,
+        avatar_template: "/letter_avatar_proxy/v4/letter/b/f0a364/{size}.png",

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

GitHub sha: 6a7ea66670dbb9b2b3f331c8d610d9efed879bfe

This commit appears in #14293 which was approved by ZogStriP. It was merged by nbianca.