Skip to content

Commit 86ef9e1

Browse files
authored
Merge pull request #405 from FromDoppler/DAT-2868
Dat 2868 - Enable/Disable Web App Features Based on 2FA Status
2 parents f2c4c68 + 764aa51 commit 86ef9e1

9 files changed

Lines changed: 161 additions & 40 deletions

File tree

src/karma.conf.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ module.exports = function (config) {
7575
__dirname + '/wwwroot/filters/numberFormat.js',
7676
__dirname + '/wwwroot/services/auth.js',
7777
__dirname + '/wwwroot/services/clerk.js',
78+
__dirname + '/wwwroot/services/featureGating.js',
7879
__dirname + '/wwwroot/services/linkUtilities.js',
7980
__dirname + '/wwwroot/services/reports.js',
8081
__dirname + '/wwwroot/services/templates.js',

src/wwwroot/controllers/MyProfileCtrl.js

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
1414
'$timeout',
1515
'settings',
1616
'clerk',
17-
'RELAY_CONFIG'
17+
'RELAY_CONFIG',
18+
'featureGating'
1819
];
1920

20-
function MyProfileCtrl($scope, $location, $rootScope, auth, $translate, $timeout, settings, clerk, RELAY_CONFIG, $http) {
21+
function MyProfileCtrl($scope, $location, $rootScope, auth, $translate, $timeout, settings, clerk, RELAY_CONFIG, featureGating, $http) {
2122
var vm = this;
2223
$rootScope.setSubmenues([
2324
{ text: 'submenu_my_profile', url: 'settings/my-profile', active: true },
@@ -44,6 +45,15 @@
4445
vm.emailResendSuccess = false;
4546
vm.pendingNewEmail = null;
4647
vm.twoFactorRequiredMessage = null;
48+
vm.emailChange2faStatus = 'allowed';
49+
50+
refreshEmailChange2faStatus();
51+
52+
function refreshEmailChange2faStatus() {
53+
featureGating.evaluate('update_email').then(function (status) {
54+
vm.emailChange2faStatus = status;
55+
});
56+
}
4757

4858
function updateValidation(form) {
4959
if (!form.pass.$modelValue || !form.confPass.$modelValue) {
@@ -149,14 +159,14 @@
149159
return;
150160
}
151161

152-
clerk.hasTwoFactorEnabled()
153-
.then(function(enabled) {
154-
if (enabled) {
155-
vm.showUserNameContainer = true;
156-
} else {
157-
vm.twoFactorRequiredMessage = $translate.instant('two_factor_required_for_action');
158-
}
159-
});
162+
featureGating.evaluate('update_email').then(function(status) {
163+
vm.emailChange2faStatus = status;
164+
if (status === featureGating.STATUS_BLOCKED_NEEDS_2FA) {
165+
vm.twoFactorRequiredMessage = $translate.instant('two_factor_required_for_action');
166+
return;
167+
}
168+
vm.showUserNameContainer = true;
169+
});
160170
}
161171

162172
function changeUsername(form) {
@@ -171,30 +181,23 @@
171181
var useClerkAuth = RELAY_CONFIG.useClerkAuthentication || false;
172182

173183
if (useClerkAuth) {
174-
clerk.hasTwoFactorEnabled()
175-
.then(function(enabled) {
176-
if (!enabled) {
177-
vm.emailChangeError = $translate.instant('two_factor_required_for_action');
184+
clerk.createEmailAddress(form.username.$modelValue)
185+
.then(function(result) {
186+
if (!result.success) {
187+
if (result.emailAlreadyExists) {
188+
vm.existingEmail = true;
189+
return;
190+
}
191+
if (result.invalidFormat) {
192+
vm.emailChangeError = $translate.instant('change_email_invalid_format');
193+
return;
194+
}
195+
vm.emailChangeError = result.error || $translate.instant('change_email_error');
178196
return;
179197
}
180-
return clerk.createEmailAddress(form.username.$modelValue)
181-
.then(function(result) {
182-
if (!result.success) {
183-
if (result.emailAlreadyExists) {
184-
vm.existingEmail = true;
185-
return;
186-
}
187-
if (result.invalidFormat) {
188-
vm.emailChangeError = $translate.instant('change_email_invalid_format');
189-
return;
190-
}
191-
vm.emailChangeError = result.error || $translate.instant('change_email_error');
192-
return;
193-
}
194198

195-
vm.emailChangeStep = 'otp';
196-
vm.pendingNewEmail = form.username.$modelValue;
197-
});
199+
vm.emailChangeStep = 'otp';
200+
vm.pendingNewEmail = form.username.$modelValue;
198201
})
199202
.catch(function(error) {
200203
vm.emailChangeError = $translate.instant('change_email_error');

src/wwwroot/controllers/SettingsCtrl.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
'settings',
1313
'$translate',
1414
'ModalService',
15-
'auth'
15+
'auth',
16+
'featureGating'
1617
];
1718

18-
function SettingsCtrl($scope, $rootScope, RELAY_CONFIG, settings, $translate, ModalService, auth) {
19+
function SettingsCtrl($scope, $rootScope, RELAY_CONFIG, settings, $translate, ModalService, auth, featureGating) {
1920
$rootScope.setSubmenues([
2021
{ text: 'domains_text', url: 'settings/domain-manager', active: false },
21-
{ text: 'submenu_smtp', url: 'settings/connection-settings', active: true }
22+
{ text: 'submenu_smtp', url: 'settings/connection-settings', active: true }
2223
]);
2324
var vm = this;
2425
vm.loadInProgress = true;
@@ -30,12 +31,31 @@
3031
vm.toggleShowPassword = function () {
3132
vm.inputType = vm.inputType != 'password' ? 'password' : 'text';
3233
}
33-
34+
3435
vm.apiKeySentSuccefully = false;
3536
vm.apiKeySentFailed = false;
3637
vm.canManageApiKey = auth.canManageApiKey();
38+
vm.apiKey2faStatus = 'allowed';
39+
40+
refreshApiKey2faStatus();
41+
42+
function refreshApiKey2faStatus() {
43+
featureGating.evaluate('manage_apikeys').then(function (status) {
44+
vm.apiKey2faStatus = status;
45+
});
46+
}
3747

3848
vm.requestApiKey = function () {
49+
featureGating.evaluate('manage_apikeys').then(function (status) {
50+
vm.apiKey2faStatus = status;
51+
if (status === featureGating.STATUS_BLOCKED_NEEDS_2FA) {
52+
return;
53+
}
54+
doRequestApiKey();
55+
});
56+
}
57+
58+
function doRequestApiKey() {
3959
vm.loadInProgress = true;
4060
vm.apiKeySentSuccefully = false;
4161
vm.apiKeySentFailed = false;
@@ -54,6 +74,16 @@
5474
}
5575

5676
vm.resetApiKey = function() {
77+
featureGating.evaluate('manage_apikeys').then(function (status) {
78+
vm.apiKey2faStatus = status;
79+
if (status === featureGating.STATUS_BLOCKED_NEEDS_2FA) {
80+
return;
81+
}
82+
showResetApiKeyConfirmation();
83+
});
84+
}
85+
86+
function showResetApiKeyConfirmation() {
5787
ModalService.showModal({
5888
templateUrl: 'partials/modals/confirm.html',
5989
controller: 'Confirm',

src/wwwroot/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
<script src="filters/numberFormat.js"></script>
145145
<script src="services/auth.js"></script>
146146
<script src="services/clerk.js"></script>
147+
<script src="services/featureGating.js"></script>
147148
<script src="services/linkUtilities.js"></script>
148149
<script src="services/reports.js"></script>
149150
<script src="services/templates.js"></script>

src/wwwroot/partials/settings/connection-settings.html

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,18 @@ <h2>{{'connection-settings_title' | translate}}</h2>
3636
<div class="api-key-actions">
3737
<label>{{'connection-settings_api_key_title' | translate}}</label>
3838
<p>
39-
<button class="button button--small" type="submit" ng-click="vm.requestApiKey()">{{'connection-settings_request_api_key' | translate}}</button>
40-
<button class="button button--small button--white" type="submit" ng-click="vm.resetApiKey()">{{'connection-settings_reset_api_key' | translate}}</button>
39+
<button class="button button--small" type="submit"
40+
ng-class="{ 'is-disabled': vm.apiKey2faStatus === 'blocked-needs-2fa' }"
41+
ng-click="vm.apiKey2faStatus === 'blocked-needs-2fa' ? null : vm.requestApiKey()"
42+
tooltips
43+
tooltip-template="{{ 'two_factor_required_for_action' | translate }}"
44+
tooltip-hidden="{{ vm.apiKey2faStatus !== 'blocked-needs-2fa' }}">{{'connection-settings_request_api_key' | translate}}</button>
45+
<button class="button button--small button--white" type="submit"
46+
ng-class="{ 'is-disabled': vm.apiKey2faStatus === 'blocked-needs-2fa' }"
47+
ng-click="vm.apiKey2faStatus === 'blocked-needs-2fa' ? null : vm.resetApiKey()"
48+
tooltips
49+
tooltip-template="{{ 'two_factor_required_for_action' | translate }}"
50+
tooltip-hidden="{{ vm.apiKey2faStatus !== 'blocked-needs-2fa' }}">{{'connection-settings_reset_api_key' | translate}}</button>
4151
</p>
4252
</div>
4353
<div ng-if="vm.apiKeySentSuccefully" class="api-key-request-result">

src/wwwroot/partials/settings/my-profile.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ <h2>
3636
</div>
3737
<div class="confirm-icons--container flex" ng-show="!vm.emailChangeStep" ng-if="vm.canChangeEmail">
3838
<div class="flex" ng-class="!vm.showUserNameContainer ? 'show' : ''">
39-
<svg class="icon edit-icon" ng-click="vm.startEmailChange();">
39+
<svg class="icon edit-icon"
40+
ng-class="{ 'is-disabled': vm.emailChange2faStatus === 'blocked-needs-2fa' }"
41+
ng-click="vm.emailChange2faStatus === 'blocked-needs-2fa' ? null : vm.startEmailChange();"
42+
tooltips
43+
tooltip-template="{{ 'two_factor_required_for_action' | translate }}"
44+
tooltip-hidden="{{ vm.emailChange2faStatus !== 'blocked-needs-2fa' }}">
4045
<use xlink:href="/images/sprite.svg#doppler-icon-edit-icon"></use>
4146
</svg>
4247
</div>

src/wwwroot/services/auth.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
changeEmail: changeEmail,
4747
isUrlAllowed: isUrlAllowed,
4848
getDefaultUrl: getDefaultUrl,
49-
isImpersonating: isImpersonating
49+
isImpersonating: isImpersonating,
50+
getFeature2FaOverride: getFeature2FaOverride
5051
};
5152
var loginSession = null;
5253
// Flag to skip restoring session while navigating to routes that require logout
@@ -143,7 +144,8 @@
143144
expiration: decodedToken.exp,
144145
temporaryToken: decodedToken.relay_temporal_token,
145146
forceMsEditor: decodedToken.force_mseditor,
146-
profile: decodedToken.profile
147+
profile: decodedToken.profile,
148+
feat2FaBloq: decodedToken.feat_2fa_bloq || {}
147149
};
148150
}
149151

@@ -566,5 +568,16 @@
566568
function isImpersonating() {
567569
return _isImpersonating;
568570
}
571+
572+
// Reads the `feat_2fa_bloq` claim for a feature. Returns the explicit
573+
// override (true = gated behind 2FA, false = 2FA bypass) or undefined when
574+
// the claim does not mention it, so callers fall back to the default.
575+
function getFeature2FaOverride(featureName) {
576+
if (!loginSession || !loginSession.feat2FaBloq) {
577+
return undefined;
578+
}
579+
var value = loginSession.feat2FaBloq[featureName];
580+
return value === true || value === false ? value : undefined;
581+
}
569582
}
570583
})();
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
(function () {
2+
'use strict';
3+
4+
angular
5+
.module('dopplerRelay')
6+
.factory('featureGating', featureGating);
7+
8+
featureGating.$inject = ['$q', 'auth', 'clerk', 'RELAY_CONFIG'];
9+
10+
function featureGating($q, auth, clerk, RELAY_CONFIG) {
11+
var STATUS_ALLOWED = 'allowed';
12+
var STATUS_BLOCKED_NEEDS_2FA = 'blocked-needs-2fa';
13+
14+
var DEFAULT_2FA_GATED = {
15+
update_email: true,
16+
manage_apikeys: true
17+
};
18+
19+
return {
20+
STATUS_ALLOWED: STATUS_ALLOWED,
21+
STATUS_BLOCKED_NEEDS_2FA: STATUS_BLOCKED_NEEDS_2FA,
22+
isGated: isGated,
23+
evaluate: evaluate
24+
};
25+
26+
function isGated(featureName) {
27+
var override = auth.getFeature2FaOverride(featureName);
28+
if (override === true || override === false) {
29+
return override;
30+
}
31+
return DEFAULT_2FA_GATED[featureName] === true;
32+
}
33+
34+
function evaluate(featureName) {
35+
if (!isGated(featureName)) {
36+
return $q.when(STATUS_ALLOWED);
37+
}
38+
if (!RELAY_CONFIG.useClerkAuthentication) {
39+
return $q.when(STATUS_ALLOWED);
40+
}
41+
return clerk.hasTwoFactorEnabled().then(function (enabled) {
42+
return enabled ? STATUS_ALLOWED : STATUS_BLOCKED_NEEDS_2FA;
43+
});
44+
}
45+
}
46+
})();

src/wwwroot/styles/views/_settings.scss

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@
4848
div.api-key-actions p button{
4949
margin-right: 5px;
5050
}
51+
52+
div.api-key-actions p button.is-disabled,
53+
div.api-key-actions p button.is-disabled:hover{
54+
opacity: 0.35;
55+
cursor: not-allowed;
56+
pointer-events: auto;
57+
}
5158
}
5259
.spinner{
5360
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 @@
321328
border-bottom: 1px solid $suit-accent-color;
322329
cursor: pointer;
323330
}
331+
.edit-icon.is-disabled {
332+
opacity: 0.35;
333+
cursor: not-allowed;
334+
pointer-events: auto;
335+
}
324336
}
325337
.item.user{
326338
transition: all .3s ease;

0 commit comments

Comments
 (0)