diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 82be216d0d9..85129d453eb 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -1,37 +1,28 @@ import { default as computed } from "ember-addons/ember-computed-decorators"; +import CanCheckEmails from "discourse/mixins/can-check-emails"; import { default as DiscourseURL, userPath } from "discourse/lib/url"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { findAll } from "discourse/models/login-method"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; +import showModal from "discourse/lib/show-modal"; -export default Ember.Controller.extend({ +export default Ember.Controller.extend(CanCheckEmails, { loading: false, + dirty: false, resetPasswordLoading: false, resetPasswordProgress: "", password: null, - secondFactorImage: null, - secondFactorKey: null, - showSecondFactorKey: false, errorMessage: null, newUsername: null, backupEnabled: Ember.computed.alias("model.second_factor_backup_enabled"), secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, + totps: null, loaded: Ember.computed.and("secondFactorImage", "secondFactorKey"), - @computed("loading") - submitButtonText(loading) { - return loading ? "loading" : "continue"; - }, - - @computed("loading") - enableButtonText(loading) { - return loading ? "loading" : "enable"; - }, - - @computed("loading") - disableButtonText(loading) { - return loading ? "loading" : "disable"; + init() { + this._super(...arguments); + this.set("totps", []); }, @computed @@ -41,58 +32,64 @@ export default Ember.Controller.extend({ @computed("currentUser") showEnforcedNotice(user) { - return user && user.get("enforcedSecondFactor"); + return user && user.enforcedSecondFactor; }, - toggleSecondFactor(enable) { - if (!this.secondFactorToken) return; + handleError(error) { + if (error.jqXHR) { + error = error.jqXHR; + } + let parsedJSON = error.responseJSON; + if (parsedJSON.error_type === "invalid_access") { + const usernameLower = this.model.username.toLowerCase(); + DiscourseURL.redirectTo( + userPath(`${usernameLower}/preferences/second-factor`) + ); + } else { + popupAjaxError(error); + } + }, + + loadSecondFactors() { + if (this.dirty === false) { + return; + } this.set("loading", true); this.model - .toggleSecondFactor( - this.secondFactorToken, - this.secondFactorMethod, - SECOND_FACTOR_METHODS.TOTP, - enable - ) + .loadSecondFactorCodes(this.password) .then(response => { if (response.error) { this.set("errorMessage", response.error); return; } - this.set("errorMessage", null); - DiscourseURL.redirectTo( - userPath(`${this.model.username.toLowerCase()}/preferences`) + this.setProperties({ + errorMessage: null, + loaded: true, + totps: response.totps, + password: null, + dirty: false + }); + this.set( + "model.second_factor_enabled", + response.totps && response.totps.length > 0 ); }) - .catch(error => { - popupAjaxError(error); - }) + .catch(e => this.handleError(e)) .finally(() => this.set("loading", false)); }, + markDirty() { + this.set("dirty", true); + }, + actions: { confirmPassword() { if (!this.password) return; - this.set("loading", true); - - this.model - .loadSecondFactorCodes(this.password) - .then(response => { - if (response.error) { - this.set("errorMessage", response.error); - return; - } - - this.setProperties({ - errorMessage: null, - secondFactorKey: response.key, - secondFactorImage: response.qr - }); - }) - .catch(popupAjaxError) - .finally(() => this.set("loading", false)); + this.markDirty(); + this.loadSecondFactors(); + this.set("password", null); }, resetPassword() { @@ -113,16 +110,66 @@ export default Ember.Controller.extend({ .finally(() => this.set("resetPasswordLoading", false)); }, - showSecondFactorKey() { - this.set("showSecondFactorKey", true); + disableAllSecondFactors() { + if (this.loading) { + return; + } + bootbox.confirm( + I18n.t("user.second_factor.disable_confirm"), + I18n.t("cancel"), + I18n.t("user.second_factor.disable"), + result => { + if (result) { + this.model + .disableAllSecondFactors() + .then(() => { + const usernameLower = this.model.username.toLowerCase(); + DiscourseURL.redirectTo( + userPath(`${usernameLower}/preferences`) + ); + }) + .catch(e => this.handleError(e)) + .finally(() => this.set("loading", false)); + } + } + ); }, - enableSecondFactor() { - this.toggleSecondFactor(true); + createTotp() { + const controller = showModal("second-factor-add-totp", { + model: this.model, + title: "user.second_factor.totp.add" + }); + controller.setProperties({ + onClose: () => this.loadSecondFactors(), + markDirty: () => this.markDirty(), + onError: e => this.handleError(e) + }); }, - disableSecondFactor() { - this.toggleSecondFactor(false); + editSecondFactor(second_factor) { + const controller = showModal("second-factor-edit", { + model: second_factor, + title: "user.second_factor.edit_title" + }); + controller.setProperties({ + user: this.model, + onClose: () => this.loadSecondFactors(), + markDirty: () => this.markDirty(), + onError: e => this.handleError(e) + }); + }, + + editSecondFactorBackup() { + const controller = showModal("second-factor-backup-edit", { + model: this.model, + title: "user.second_factor_backup.title" + }); + controller.setProperties({ + onClose: () => this.loadSecondFactors(), + markDirty: () => this.markDirty(), + onError: e => this.handleError(e) + }); } } }); diff --git a/app/assets/javascripts/discourse/controllers/second-factor-add-totp.js.es6 b/app/assets/javascripts/discourse/controllers/second-factor-add-totp.js.es6 new file mode 100644 index 00000000000..e826f934cbf --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/second-factor-add-totp.js.es6 @@ -0,0 +1,67 @@ +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default Ember.Controller.extend(ModalFunctionality, { + loading: false, + secondFactorImage: null, + secondFactorKey: null, + showSecondFactorKey: false, + errorMessage: null, + + onShow() { + this.setProperties({ + errorMessage: null, + secondFactorKey: null, + secondFactorToken: null, + showSecondFactorKey: false, + secondFactorImage: null, + loading: true + }); + this.model + .createSecondFactorTotp() + .then(response => { + if (response.error) { + this.set("errorMessage", response.error); + return; + } + + this.setProperties({ + errorMessage: null, + secondFactorKey: response.key, + secondFactorImage: response.qr + }); + }) + .catch(error => { + this.send("closeModal"); + this.onError(error); + }) + .finally(() => this.set("loading", false)); + }, + + actions: { + showSecondFactorKey() { + this.set("showSecondFactorKey", true); + }, + + enableSecondFactor() { + if (!this.secondFactorToken) return; + this.set("loading", true); + + this.model + .enableSecondFactorTotp( + this.secondFactorToken, + I18n.t("user.second_factor.totp.default_name") + ) + .then(response => { + if (response.error) { + this.set("errorMessage", response.error); + return; + } + this.markDirty(); + this.set("errorMessage", null); + this.send("closeModal"); + }) + .catch(error => this.onError(error)) + .finally(() => this.set("loading", false)); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 b/app/assets/javascripts/discourse/controllers/second-factor-backup-edit.js.es6 similarity index 57% rename from app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 rename to app/assets/javascripts/discourse/controllers/second-factor-backup-edit.js.es6 index e5817ac9fa3..e11f484fbc5 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor-backup.js.es6 +++ b/app/assets/javascripts/discourse/controllers/second-factor-backup-edit.js.es6 @@ -1,9 +1,8 @@ import { default as computed } from "ember-addons/ember-computed-decorators"; -import { default as DiscourseURL, userPath } from "discourse/lib/url"; -import { popupAjaxError } from "discourse/lib/ajax-error"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; -export default Ember.Controller.extend({ +export default Ember.Controller.extend(ModalFunctionality, { loading: false, errorMessage: null, successMessage: null, @@ -14,25 +13,6 @@ export default Ember.Controller.extend({ backupCodes: null, secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, - @computed("secondFactorToken", "secondFactorMethod") - isValidSecondFactorToken(secondFactorToken, secondFactorMethod) { - if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) { - return secondFactorToken && secondFactorToken.length === 6; - } else if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) { - return secondFactorToken && secondFactorToken.length === 16; - } - }, - - @computed("isValidSecondFactorToken", "backupEnabled", "loading") - isDisabledGenerateBackupCodeBtn(isValid, backupEnabled, loading) { - return !isValid || loading; - }, - - @computed("isValidSecondFactorToken", "backupEnabled", "loading") - isDisabledDisableBackupCodeBtn(isValid, backupEnabled, loading) { - return !isValid || !backupEnabled || loading; - }, - @computed("backupEnabled") generateBackupCodeBtnLabel(backupEnabled) { return backupEnabled @@ -40,6 +20,15 @@ export default Ember.Controller.extend({ : "user.second_factor_backup.enable"; }, + onShow() { + this.setProperties({ + loading: false, + errorMessage: null, + successMessage: null, + backupCodes: null + }); + }, + actions: { copyBackupCode(successful) { if (successful) { @@ -59,18 +48,10 @@ export default Ember.Controller.extend({ disableSecondFactorBackup() { this.set("backupCodes", []); - - if (!this.secondFactorToken) return; - this.set("loading", true); this.model - .toggleSecondFactor( - this.secondFactorToken, - this.secondFactorMethod, - SECOND_FACTOR_METHODS.BACKUP_CODE, - false - ) + .updateSecondFactor(0, "", true, SECOND_FACTOR_METHODS.BACKUP_CODE) .then(response => { if (response.error) { this.set("errorMessage", response.error); @@ -78,28 +59,28 @@ export default Ember.Controller.extend({ } this.set("errorMessage", null); - - const usernameLower = this.model.username.toLowerCase(); - DiscourseURL.redirectTo(userPath(`${usernameLower}/preferences`)); + this.model.set("second_factor_backup_enabled", false); + this.markDirty(); + this.send("closeModal"); + }) + .catch(error => { + this.send("closeModal"); + this.onError(error); }) - .catch(popupAjaxError) .finally(() => this.set("loading", false)); }, generateSecondFactorCodes() { - if (!this.secondFactorToken) return; this.set("loading", true); this.model - .generateSecondFactorCodes( - this.secondFactorToken, - this.secondFactorMethod - ) + .generateSecondFactorCodes() .then(response => { if (response.error) { this.set("errorMessage", response.error); return; } + this.markDirty(); this.setProperties({ errorMessage: null, backupCodes: response.backup_codes, @@ -107,11 +88,13 @@ export default Ember.Controller.extend({ remainingCodes: response.backup_codes.length }); }) - .catch(popupAjaxError) + .catch(error => { + this.send("closeModal"); + this.onError(error); + }) .finally(() => { this.setProperties({ - loading: false, - secondFactorToken: null + loading: false }); }); } diff --git a/app/assets/javascripts/discourse/controllers/second-factor-edit.js.es6 b/app/assets/javascripts/discourse/controllers/second-factor-edit.js.es6 new file mode 100644 index 00000000000..e39f0f2b659 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/second-factor-edit.js.es6 @@ -0,0 +1,53 @@ +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default Ember.Controller.extend(ModalFunctionality, { + actions: { + disableSecondFactor() { + this.user + .updateSecondFactor( + this.model.id, + this.model.name, + true, + this.model.method + ) + .then(response => { + if (response.error) { + return; + } + this.markDirty(); + }) + .catch(error => { + this.send("closeModal"); + this.onError(error); + }) + .finally(() => { + this.set("loading", false); + this.send("closeModal"); + }); + }, + + editSecondFactor() { + this.user + .updateSecondFactor( + this.model.id, + this.model.name, + false, + this.model.method + ) + .then(response => { + if (response.error) { + return; + } + this.markDirty(); + }) + .catch(error => { + this.send("closeModal"); + this.onError(error); + }) + .finally(() => { + this.set("loading", false); + this.send("closeModal"); + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index b9064d13f62..21603e8ed96 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -205,17 +205,13 @@ const User = RestModel.extend({ return suspendedTill && moment(suspendedTill).isAfter(); }, - @computed("suspended_till") - suspendedForever: isForever, + @computed("suspended_till") suspendedForever: isForever, - @computed("silenced_till") - silencedForever: isForever, + @computed("silenced_till") silencedForever: isForever, - @computed("suspended_till") - suspendedTillDate: longDate, + @computed("suspended_till") suspendedTillDate: longDate, - @computed("silenced_till") - silencedTillDate: longDate, + @computed("silenced_till") silencedTillDate: longDate, changeUsername(new_username) { return ajax(userPath(`${this.username_lower}/preferences/username`), { @@ -366,6 +362,40 @@ const User = RestModel.extend({ }); }, + createSecondFactorTotp() { + return ajax("/u/create_second_factor_totp.json", { + type: "POST" + }); + }, + + enableSecondFactorTotp(authToken, name) { + return ajax("/u/enable_second_factor_totp.json", { + data: { + second_factor_token: authToken, + name + }, + type: "POST" + }); + }, + + disableAllSecondFactors() { + return ajax("/u/disable_second_factor.json", { + type: "PUT" + }); + }, + + updateSecondFactor(id, name, disable, targetMethod) { + return ajax("/u/second_factor.json", { + data: { + second_factor_target: targetMethod, + name, + disable, + id + }, + type: "PUT" + }); + }, + toggleSecondFactor(authToken, authMethod, targetMethod, enable) { return ajax("/u/second_factor.json", { data: { @@ -378,12 +408,8 @@ const User = RestModel.extend({ }); }, - generateSecondFactorCodes(authToken, authMethod) { + generateSecondFactorCodes() { return ajax("/u/second_factors_backup.json", { - data: { - second_factor_token: authToken, - second_factor_method: authMethod - }, type: "PUT" }); }, diff --git a/app/assets/javascripts/discourse/routes/preferences-second-factor-backup.js.es6 b/app/assets/javascripts/discourse/routes/preferences-second-factor-backup.js.es6 deleted file mode 100644 index afe26e906a8..00000000000 --- a/app/assets/javascripts/discourse/routes/preferences-second-factor-backup.js.es6 +++ /dev/null @@ -1,21 +0,0 @@ -import RestrictedUserRoute from "discourse/routes/restricted-user"; - -export default RestrictedUserRoute.extend({ - showFooter: true, - - model() { - return this.modelFor("user"); - }, - - renderTemplate() { - return this.render({ into: "user" }); - }, - - setupController(controller, model) { - controller.setProperties({ model, newUsername: model.get("username") }); - }, - - deactivate() { - this.controller.setProperties({ backupCodes: null }); - } -}); diff --git a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 index 703bcf32c37..c9f2bc8250d 100644 --- a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 @@ -13,6 +13,23 @@ export default RestrictedUserRoute.extend({ setupController(controller, model) { controller.setProperties({ model, newUsername: model.get("username") }); + controller.set("loading", true); + model + .loadSecondFactorCodes("") + .then(response => { + if (response.error) { + controller.set("errorMessage", response.error); + } else { + controller.setProperties({ + errorMessage: null, + loaded: !response.password_required, + dirty: !!response.password_required, + totps: response.totps + }); + } + }) + .catch(controller.popupAjaxError) + .finally(() => controller.set("loading", false)); }, actions: { diff --git a/app/assets/javascripts/discourse/templates/modal/second-factor-add-totp.hbs b/app/assets/javascripts/discourse/templates/modal/second-factor-add-totp.hbs new file mode 100644 index 00000000000..4f44cd0678e --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/second-factor-add-totp.hbs @@ -0,0 +1,51 @@ +{{#d-modal-body}} + {{#conditional-loading-spinner condition=loading}} + {{#if errorMessage}} +
+ {{#if showSecondFactorKey}} + {{secondFactorKey}} + {{else}} + {{i18n 'user.second_factor.show_key_description'}} + {{/if}} +
+