diff --git a/src/karma.conf.js b/src/karma.conf.js index 97e91a70..6e192d1e 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -75,6 +75,7 @@ module.exports = function (config) { __dirname + '/wwwroot/filters/numberFormat.js', __dirname + '/wwwroot/services/auth.js', __dirname + '/wwwroot/services/clerk.js', + __dirname + '/wwwroot/services/featureGating.js', __dirname + '/wwwroot/services/linkUtilities.js', __dirname + '/wwwroot/services/reports.js', __dirname + '/wwwroot/services/templates.js', diff --git a/src/wwwroot/controllers/MyProfileCtrl.js b/src/wwwroot/controllers/MyProfileCtrl.js index 274101f0..b9e96efe 100644 --- a/src/wwwroot/controllers/MyProfileCtrl.js +++ b/src/wwwroot/controllers/MyProfileCtrl.js @@ -14,10 +14,11 @@ '$timeout', 'settings', 'clerk', - 'RELAY_CONFIG' + 'RELAY_CONFIG', + 'featureGating' ]; - function MyProfileCtrl($scope, $location, $rootScope, auth, $translate, $timeout, settings, clerk, RELAY_CONFIG, $http) { + function MyProfileCtrl($scope, $location, $rootScope, auth, $translate, $timeout, settings, clerk, RELAY_CONFIG, featureGating, $http) { var vm = this; $rootScope.setSubmenues([ { text: 'submenu_my_profile', url: 'settings/my-profile', active: true }, @@ -44,6 +45,15 @@ vm.emailResendSuccess = false; vm.pendingNewEmail = null; vm.twoFactorRequiredMessage = null; + vm.emailChange2faStatus = 'allowed'; + + refreshEmailChange2faStatus(); + + function refreshEmailChange2faStatus() { + featureGating.evaluate('update_email').then(function (status) { + vm.emailChange2faStatus = status; + }); + } function updateValidation(form) { if (!form.pass.$modelValue || !form.confPass.$modelValue) { @@ -149,14 +159,14 @@ return; } - clerk.hasTwoFactorEnabled() - .then(function(enabled) { - if (enabled) { - vm.showUserNameContainer = true; - } else { - vm.twoFactorRequiredMessage = $translate.instant('two_factor_required_for_action'); - } - }); + featureGating.evaluate('update_email').then(function(status) { + vm.emailChange2faStatus = status; + if (status === featureGating.STATUS_BLOCKED_NEEDS_2FA) { + vm.twoFactorRequiredMessage = $translate.instant('two_factor_required_for_action'); + return; + } + vm.showUserNameContainer = true; + }); } function changeUsername(form) { @@ -171,30 +181,23 @@ var useClerkAuth = RELAY_CONFIG.useClerkAuthentication || false; if (useClerkAuth) { - clerk.hasTwoFactorEnabled() - .then(function(enabled) { - if (!enabled) { - vm.emailChangeError = $translate.instant('two_factor_required_for_action'); + clerk.createEmailAddress(form.username.$modelValue) + .then(function(result) { + if (!result.success) { + if (result.emailAlreadyExists) { + vm.existingEmail = true; + return; + } + if (result.invalidFormat) { + vm.emailChangeError = $translate.instant('change_email_invalid_format'); + return; + } + vm.emailChangeError = result.error || $translate.instant('change_email_error'); return; } - return clerk.createEmailAddress(form.username.$modelValue) - .then(function(result) { - if (!result.success) { - if (result.emailAlreadyExists) { - vm.existingEmail = true; - return; - } - if (result.invalidFormat) { - vm.emailChangeError = $translate.instant('change_email_invalid_format'); - return; - } - vm.emailChangeError = result.error || $translate.instant('change_email_error'); - return; - } - vm.emailChangeStep = 'otp'; - vm.pendingNewEmail = form.username.$modelValue; - }); + vm.emailChangeStep = 'otp'; + vm.pendingNewEmail = form.username.$modelValue; }) .catch(function(error) { vm.emailChangeError = $translate.instant('change_email_error'); diff --git a/src/wwwroot/controllers/SettingsCtrl.js b/src/wwwroot/controllers/SettingsCtrl.js index bef106c6..19a9135e 100644 --- a/src/wwwroot/controllers/SettingsCtrl.js +++ b/src/wwwroot/controllers/SettingsCtrl.js @@ -12,13 +12,14 @@ 'settings', '$translate', 'ModalService', - 'auth' + 'auth', + 'featureGating' ]; - function SettingsCtrl($scope, $rootScope, RELAY_CONFIG, settings, $translate, ModalService, auth) { + function SettingsCtrl($scope, $rootScope, RELAY_CONFIG, settings, $translate, ModalService, auth, featureGating) { $rootScope.setSubmenues([ { text: 'domains_text', url: 'settings/domain-manager', active: false }, - { text: 'submenu_smtp', url: 'settings/connection-settings', active: true } + { text: 'submenu_smtp', url: 'settings/connection-settings', active: true } ]); var vm = this; vm.loadInProgress = true; @@ -30,12 +31,31 @@ vm.toggleShowPassword = function () { vm.inputType = vm.inputType != 'password' ? 'password' : 'text'; } - + vm.apiKeySentSuccefully = false; vm.apiKeySentFailed = false; vm.canManageApiKey = auth.canManageApiKey(); + vm.apiKey2faStatus = 'allowed'; + + refreshApiKey2faStatus(); + + function refreshApiKey2faStatus() { + featureGating.evaluate('manage_apikeys').then(function (status) { + vm.apiKey2faStatus = status; + }); + } vm.requestApiKey = function () { + featureGating.evaluate('manage_apikeys').then(function (status) { + vm.apiKey2faStatus = status; + if (status === featureGating.STATUS_BLOCKED_NEEDS_2FA) { + return; + } + doRequestApiKey(); + }); + } + + function doRequestApiKey() { vm.loadInProgress = true; vm.apiKeySentSuccefully = false; vm.apiKeySentFailed = false; @@ -54,6 +74,16 @@ } vm.resetApiKey = function() { + featureGating.evaluate('manage_apikeys').then(function (status) { + vm.apiKey2faStatus = status; + if (status === featureGating.STATUS_BLOCKED_NEEDS_2FA) { + return; + } + showResetApiKeyConfirmation(); + }); + } + + function showResetApiKeyConfirmation() { ModalService.showModal({ templateUrl: 'partials/modals/confirm.html', controller: 'Confirm', diff --git a/src/wwwroot/index.html b/src/wwwroot/index.html index 8d8e0348..65d7a022 100644 --- a/src/wwwroot/index.html +++ b/src/wwwroot/index.html @@ -144,6 +144,7 @@ + diff --git a/src/wwwroot/partials/settings/connection-settings.html b/src/wwwroot/partials/settings/connection-settings.html index 773e6045..c6949978 100644 --- a/src/wwwroot/partials/settings/connection-settings.html +++ b/src/wwwroot/partials/settings/connection-settings.html @@ -36,8 +36,18 @@

{{'connection-settings_title' | translate}}

- - + +

diff --git a/src/wwwroot/partials/settings/my-profile.html b/src/wwwroot/partials/settings/my-profile.html index ae75fcf5..31d1d7f3 100644 --- a/src/wwwroot/partials/settings/my-profile.html +++ b/src/wwwroot/partials/settings/my-profile.html @@ -36,7 +36,12 @@

- +
diff --git a/src/wwwroot/services/auth.js b/src/wwwroot/services/auth.js index f1ac27fc..4a5f746b 100644 --- a/src/wwwroot/services/auth.js +++ b/src/wwwroot/services/auth.js @@ -46,7 +46,8 @@ changeEmail: changeEmail, isUrlAllowed: isUrlAllowed, getDefaultUrl: getDefaultUrl, - isImpersonating: isImpersonating + isImpersonating: isImpersonating, + getFeature2FaOverride: getFeature2FaOverride }; var loginSession = null; // Flag to skip restoring session while navigating to routes that require logout @@ -143,7 +144,8 @@ expiration: decodedToken.exp, temporaryToken: decodedToken.relay_temporal_token, forceMsEditor: decodedToken.force_mseditor, - profile: decodedToken.profile + profile: decodedToken.profile, + feat2FaBloq: decodedToken.feat_2fa_bloq || {} }; } @@ -566,5 +568,16 @@ function isImpersonating() { return _isImpersonating; } + + // Reads the `feat_2fa_bloq` claim for a feature. Returns the explicit + // override (true = gated behind 2FA, false = 2FA bypass) or undefined when + // the claim does not mention it, so callers fall back to the default. + function getFeature2FaOverride(featureName) { + if (!loginSession || !loginSession.feat2FaBloq) { + return undefined; + } + var value = loginSession.feat2FaBloq[featureName]; + return value === true || value === false ? value : undefined; + } } })(); diff --git a/src/wwwroot/services/featureGating.js b/src/wwwroot/services/featureGating.js new file mode 100644 index 00000000..8665b0c7 --- /dev/null +++ b/src/wwwroot/services/featureGating.js @@ -0,0 +1,46 @@ +(function () { + 'use strict'; + + angular + .module('dopplerRelay') + .factory('featureGating', featureGating); + + featureGating.$inject = ['$q', 'auth', 'clerk', 'RELAY_CONFIG']; + + function featureGating($q, auth, clerk, RELAY_CONFIG) { + var STATUS_ALLOWED = 'allowed'; + var STATUS_BLOCKED_NEEDS_2FA = 'blocked-needs-2fa'; + + var DEFAULT_2FA_GATED = { + update_email: true, + manage_apikeys: true + }; + + return { + STATUS_ALLOWED: STATUS_ALLOWED, + STATUS_BLOCKED_NEEDS_2FA: STATUS_BLOCKED_NEEDS_2FA, + isGated: isGated, + evaluate: evaluate + }; + + function isGated(featureName) { + var override = auth.getFeature2FaOverride(featureName); + if (override === true || override === false) { + return override; + } + return DEFAULT_2FA_GATED[featureName] === true; + } + + function evaluate(featureName) { + if (!isGated(featureName)) { + return $q.when(STATUS_ALLOWED); + } + if (!RELAY_CONFIG.useClerkAuthentication) { + return $q.when(STATUS_ALLOWED); + } + return clerk.hasTwoFactorEnabled().then(function (enabled) { + return enabled ? STATUS_ALLOWED : STATUS_BLOCKED_NEEDS_2FA; + }); + } + } +})(); diff --git a/src/wwwroot/styles/views/_settings.scss b/src/wwwroot/styles/views/_settings.scss index 6c0a973e..89c01ce3 100644 --- a/src/wwwroot/styles/views/_settings.scss +++ b/src/wwwroot/styles/views/_settings.scss @@ -48,6 +48,13 @@ div.api-key-actions p button{ margin-right: 5px; } + + div.api-key-actions p button.is-disabled, + div.api-key-actions p button.is-disabled:hover{ + opacity: 0.35; + cursor: not-allowed; + pointer-events: auto; + } } .spinner{ fill:$suit-accent-color; padding:torem(15px);background-color:initial;height:torem(59px);width:torem(59px);border-radius:0 5px 5px 0;left: 50%;top:100%;transform: translate(-50%,-50%);position: absolute; @@ -321,6 +328,11 @@ border-bottom: 1px solid $suit-accent-color; cursor: pointer; } + .edit-icon.is-disabled { + opacity: 0.35; + cursor: not-allowed; + pointer-events: auto; + } } .item.user{ transition: all .3s ease;