diff --git a/src/sentry/api/bases/user.py b/src/sentry/api/bases/user.py
index a1aa7127663958..3946b449404754 100644
--- a/src/sentry/api/bases/user.py
+++ b/src/sentry/api/bases/user.py
@@ -2,12 +2,12 @@
from sentry.api.base import Endpoint
from sentry.api.exceptions import ResourceDoesNotExist
-from sentry.api.permissions import ScopedPermission
-from sentry.models import User
+from sentry.api.permissions import SentryPermission
+from sentry.models import Organization, OrganizationStatus, User
from sentry.auth.superuser import is_active_superuser
-class UserPermission(ScopedPermission):
+class UserPermission(SentryPermission):
def has_object_permission(self, request, view, user=None):
if user is None:
user = request.user
@@ -20,6 +20,36 @@ def has_object_permission(self, request, view, user=None):
return False
+class OrganizationUserPermission(UserPermission):
+ scope_map = {
+ 'DELETE': ['member:admin'],
+ }
+
+ def has_org_permission(self, request, user):
+ """
+ Org can act on a user account,
+ if the user is a member of only one org
+ e.g. reset org member's 2FA
+ """
+
+ try:
+ organization = Organization.objects.get(
+ status=OrganizationStatus.VISIBLE,
+ member_set__user=user
+ )
+
+ self.determine_access(request, organization)
+ allowed_scopes = set(self.scope_map.get(request.method, []))
+ return any(request.access.has_scope(s) for s in allowed_scopes)
+ except (Organization.DoesNotExist, Organization.MultipleObjectsReturned):
+ return False
+
+ def has_object_permission(self, request, view, user=None):
+ if super(OrganizationUserPermission, self).has_object_permission(request, view, user):
+ return True
+ return self.has_org_permission(request, user)
+
+
class UserEndpoint(Endpoint):
permission_classes = (UserPermission, )
diff --git a/src/sentry/api/endpoints/organization_member_details.py b/src/sentry/api/endpoints/organization_member_details.py
index ba0fc930fae44b..4a7c75cbdb813b 100644
--- a/src/sentry/api/endpoints/organization_member_details.py
+++ b/src/sentry/api/endpoints/organization_member_details.py
@@ -9,7 +9,8 @@
from sentry.api.bases.organization import (
OrganizationEndpoint, OrganizationPermission)
from sentry.api.exceptions import ResourceDoesNotExist
-from sentry.api.serializers import serialize, RoleSerializer, OrganizationMemberWithTeamsSerializer
+from sentry.api.serializers import (
+ DetailedUserSerializer, serialize, RoleSerializer, OrganizationMemberWithTeamsSerializer)
from sentry.api.serializers.rest_framework import ListField
from sentry.auth.superuser import is_active_superuser
from sentry.models import (
@@ -105,6 +106,7 @@ def _serialize_member(self, member, request, allowed_roles=None):
if request.access.has_scope('member:admin'):
context['invite_link'] = member.get_invite_link()
+ context['user'] = serialize(member.user, request.user, DetailedUserSerializer())
context['isOnlyOwner'] = self._is_only_owner(member)
context['roles'] = serialize(
diff --git a/src/sentry/api/endpoints/user_authenticator_details.py b/src/sentry/api/endpoints/user_authenticator_details.py
index b4629d701ca8a5..ba8de0c574de14 100644
--- a/src/sentry/api/endpoints/user_authenticator_details.py
+++ b/src/sentry/api/endpoints/user_authenticator_details.py
@@ -4,7 +4,7 @@
from rest_framework import status
from rest_framework.response import Response
-from sentry.api.bases.user import UserEndpoint
+from sentry.api.bases.user import UserEndpoint, OrganizationUserPermission
from sentry.api.decorators import sudo_required
from sentry.api.serializers import serialize
from sentry.models import Authenticator
@@ -12,6 +12,8 @@
class UserAuthenticatorDetailsEndpoint(UserEndpoint):
+ permission_classes = (OrganizationUserPermission, )
+
@sudo_required
def get(self, request, user, auth_id):
"""
diff --git a/src/sentry/api/serializers/models/user.py b/src/sentry/api/serializers/models/user.py
index 1b270bddb9f6ce..5d14a40e2e6395 100644
--- a/src/sentry/api/serializers/models/user.py
+++ b/src/sentry/api/serializers/models/user.py
@@ -10,6 +10,8 @@
from sentry.models import (
AuthIdentity,
Authenticator,
+ OrganizationMember,
+ OrganizationStatus,
User,
UserAvatar,
UserOption,
@@ -150,10 +152,18 @@ def get_attrs(self, item_list, user):
user__in=item_list,
), 'user_id')
+ memberships = manytoone_to_dict(OrganizationMember.objects.filter(
+ user__in=item_list,
+ organization__status=OrganizationStatus.VISIBLE,
+ ), 'user_id')
+
for item in item_list:
attrs[item]['authenticators'] = authenticators[item.id]
attrs[item]['permissions'] = permissions[item.id]
+ # org can reset 2FA if the user is only in one org
+ attrs[item]['canReset2fa'] = len(memberships[item.id]) == 1
+
return attrs
def serialize(self, obj, attrs, user):
@@ -174,4 +184,5 @@ def serialize(self, obj, attrs, user):
'dateUsed': a.last_used_at,
} for a in attrs['authenticators']
]
+ d['canReset2fa'] = attrs['canReset2fa']
return d
diff --git a/src/sentry/static/sentry/app/actionCreators/account.jsx b/src/sentry/static/sentry/app/actionCreators/account.jsx
index 85eac708f1830a..0ede89661dcb0f 100644
--- a/src/sentry/static/sentry/app/actionCreators/account.jsx
+++ b/src/sentry/static/sentry/app/actionCreators/account.jsx
@@ -29,3 +29,9 @@ export function logout(api) {
method: 'DELETE',
});
}
+
+export function removeAuthenticator(api, userId, authId) {
+ return api.requestPromise(`/users/${userId}/authenticators/${authId}/`, {
+ method: 'DELETE',
+ });
+}
diff --git a/src/sentry/static/sentry/app/views/settings/organizationMembers/organizationMemberDetail.jsx b/src/sentry/static/sentry/app/views/settings/organizationMembers/organizationMemberDetail.jsx
index 5d8ca6d4d14786..3ea51251b39cbd 100644
--- a/src/sentry/static/sentry/app/views/settings/organizationMembers/organizationMemberDetail.jsx
+++ b/src/sentry/static/sentry/app/views/settings/organizationMembers/organizationMemberDetail.jsx
@@ -1,21 +1,35 @@
+import * as Sentry from '@sentry/browser';
+
import {browserHistory} from 'react-router';
import React from 'react';
import {resendMemberInvite, updateMember} from 'app/actionCreators/members';
-import {t} from 'app/locale';
+import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
+import {t, tct} from 'app/locale';
import AsyncView from 'app/views/asyncView';
import Button from 'app/components/button';
+import Confirm from 'app/components/confirm';
import DateTime from 'app/components/dateTime';
+import Field from 'app/views/settings/components/forms/field';
import IndicatorStore from 'app/stores/indicatorStore';
import NotFound from 'app/components/errors/notFound';
import {Panel, PanelBody, PanelHeader, PanelItem} from 'app/components/panels';
+import {removeAuthenticator} from 'app/actionCreators/account';
import SentryTypes from 'app/sentryTypes';
import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
+import Tooltip from 'app/components/tooltip';
import recreateRoute from 'app/utils/recreateRoute';
import RoleSelect from './inviteMember/roleSelect';
import TeamSelect from './inviteMember/teamSelect';
+const NOT_ENROLLED = t('Not enrolled in two-factor authentication');
+const NO_PERMISSION = t('You do not have permission to perform this action');
+const TWO_FACTOR_REQUIRED = t(
+ 'Cannot be reset since two-factor is required for this organization'
+);
+const MULTIPLE_ORGS = t('Cannot be reset since user is in more than one organization');
+
class OrganizationMemberDetail extends AsyncView {
static contextTypes = {
organization: SentryTypes.Organization,
@@ -142,16 +156,62 @@ class OrganizationMemberDetail extends AsyncView {
});
};
+ handle2faReset = () => {
+ let {user} = this.state.member;
+ let {slug} = this.getOrganization();
+
+ let requests = user.authenticators.map(auth =>
+ removeAuthenticator(this.api, user.id, auth.id)
+ );
+
+ Promise.all(requests)
+ .then(() => {
+ this.props.router.push(`/settings/${slug}/members/`);
+ addSuccessMessage(t('All authenticators have been removed'));
+ })
+ .catch(err => {
+ addErrorMessage(t('Error removing authenticators'));
+ Sentry.captureException(err);
+ });
+ };
+
+ showResetButton = () => {
+ let {member} = this.state;
+ let {require2FA} = this.getOrganization();
+ let {user} = member;
+
+ if (!user || !user.authenticators || require2FA) return false;
+ let hasAuth = user.authenticators.length >= 1;
+ return hasAuth && user.canReset2fa;
+ };
+
+ getTooltip = () => {
+ let {member} = this.state;
+ let {require2FA} = this.getOrganization();
+ let {user} = member;
+
+ if (!user) return '';
+
+ if (!user.authenticators) return NO_PERMISSION;
+ if (!user.authenticators.length) return NOT_ENROLLED;
+ if (!user.canReset2fa) return MULTIPLE_ORGS;
+ if (require2FA) return TWO_FACTOR_REQUIRED;
+
+ return '';
+ };
+
renderBody() {
let {error, member} = this.state;
let {teams, access} = this.getOrganization();
if (!member) return