accountSecurityDetails.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. /**
  2. * AccountSecurityDetails is only displayed when user is enrolled in the 2fa method.
  3. * It displays created + last used time of the 2fa method.
  4. *
  5. * Also displays 2fa method specific details.
  6. */
  7. import {Fragment} from 'react';
  8. import styled from '@emotion/styled';
  9. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  10. import {Button, LinkButton} from 'sentry/components/button';
  11. import CircleIndicator from 'sentry/components/circleIndicator';
  12. import {DateTime} from 'sentry/components/dateTime';
  13. import LoadingError from 'sentry/components/loadingError';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  16. import {Tooltip} from 'sentry/components/tooltip';
  17. import {t} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import type {Authenticator, AuthenticatorDevice} from 'sentry/types/auth';
  20. import {useApiQuery, useMutation, useQueryClient} from 'sentry/utils/queryClient';
  21. import useApi from 'sentry/utils/useApi';
  22. import {useNavigate} from 'sentry/utils/useNavigate';
  23. import {useParams} from 'sentry/utils/useParams';
  24. import RecoveryCodes from 'sentry/views/settings/account/accountSecurity/components/recoveryCodes';
  25. import RemoveConfirm from 'sentry/views/settings/account/accountSecurity/components/removeConfirm';
  26. import U2fEnrolledDetails from 'sentry/views/settings/account/accountSecurity/components/u2fEnrolledDetails';
  27. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  28. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  29. const ENDPOINT = '/users/me/authenticators/';
  30. const getAuthenticatorQueryKey = (authId: string) => [`${ENDPOINT}${authId}/`] as const;
  31. interface AuthenticatorDateProps {
  32. /**
  33. * Can be null or a Date object.
  34. * Component will have value "never" if it is null
  35. */
  36. date: string | null;
  37. label: string;
  38. }
  39. function AuthenticatorDate({label, date}: AuthenticatorDateProps) {
  40. return (
  41. <Fragment>
  42. <DateLabel>{label}</DateLabel>
  43. <div>{date ? <DateTime date={date} /> : t('never')}</div>
  44. </Fragment>
  45. );
  46. }
  47. interface Props {
  48. deleteDisabled: boolean;
  49. onRegenerateBackupCodes: () => void;
  50. }
  51. function AccountSecurityDetails({deleteDisabled, onRegenerateBackupCodes}: Props) {
  52. const api = useApi();
  53. const queryClient = useQueryClient();
  54. const navigate = useNavigate();
  55. const {authId} = useParams<{authId: string}>();
  56. const {
  57. data: authenticator,
  58. isPending: isAuthenticatorPending,
  59. isError,
  60. refetch,
  61. } = useApiQuery<Authenticator>(getAuthenticatorQueryKey(authId), {
  62. staleTime: 0,
  63. });
  64. const {mutate: remove, isLoading: isRemoveLoading} = useMutation(
  65. ({id, device}: {id: string; device?: AuthenticatorDevice}) => {
  66. // if the device is defined, it means that U2f is being removed
  67. // reason for adding a trailing slash is a result of the endpoint on line 109 needing it but it can't be set there as if deviceId is None, the route will end with '//'
  68. const deviceId = device ? `${device.key_handle}/` : '';
  69. return api.requestPromise(`${ENDPOINT}${id}/${deviceId}`, {
  70. method: 'DELETE',
  71. });
  72. },
  73. {
  74. onSuccess: (_, {device}) => {
  75. const deviceName = device ? device.name : t('Authenticator');
  76. addSuccessMessage(t('%s has been removed', deviceName));
  77. },
  78. onError: (_, {device}) => {
  79. const deviceName = device ? device.name : t('Authenticator');
  80. addErrorMessage(t('Error removing %s', deviceName));
  81. },
  82. onSettled: () => {
  83. queryClient.invalidateQueries(getAuthenticatorQueryKey(authId));
  84. },
  85. }
  86. );
  87. const {mutate: rename, isLoading: isRenameLoading} = useMutation(
  88. ({id, device, name}: {device: AuthenticatorDevice; id: string; name: string}) => {
  89. return api.requestPromise(`${ENDPOINT}${id}/${device.key_handle}/`, {
  90. method: 'PUT',
  91. data: {
  92. name,
  93. },
  94. });
  95. },
  96. {
  97. onSuccess: () => {
  98. navigate(`/settings/account/security/mfa/${authId}`);
  99. addSuccessMessage(t('Device was renamed'));
  100. },
  101. onError: () => {
  102. addErrorMessage(t('Error renaming the device'));
  103. },
  104. onSettled: () => {
  105. queryClient.invalidateQueries(getAuthenticatorQueryKey(authId));
  106. },
  107. }
  108. );
  109. const handleRemove = (device?: AuthenticatorDevice) => {
  110. if (!authenticator?.authId) {
  111. return;
  112. }
  113. remove({id: authenticator.authId, device});
  114. };
  115. const handleRename = (device: AuthenticatorDevice, deviceName: string) => {
  116. if (!authenticator?.authId) {
  117. return;
  118. }
  119. rename({id: authenticator.authId, device, name: deviceName});
  120. };
  121. if (isAuthenticatorPending || isRemoveLoading || isRenameLoading) {
  122. return <LoadingIndicator />;
  123. }
  124. if (isError) {
  125. return <LoadingError onRetry={refetch} />;
  126. }
  127. return (
  128. <SentryDocumentTitle title={t('Security')}>
  129. <SettingsPageHeader
  130. title={
  131. <Fragment>
  132. <span>{authenticator.name}</span>
  133. <AuthenticatorStatus
  134. data-test-id={`auth-status-${
  135. authenticator.isEnrolled ? 'enabled' : 'disabled'
  136. }`}
  137. enabled={authenticator.isEnrolled}
  138. />
  139. </Fragment>
  140. }
  141. action={
  142. <AuthenticatorActions>
  143. {authenticator.isEnrolled && authenticator.allowRotationInPlace && (
  144. <LinkButton
  145. to={`/settings/account/security/mfa/${authenticator.id}/enroll/`}
  146. >
  147. {t('Rotate Secret Key')}
  148. </LinkButton>
  149. )}
  150. {authenticator.isEnrolled && authenticator.removeButton && (
  151. <Tooltip
  152. title={t(
  153. "Two-factor authentication is required for at least one organization you're a member of."
  154. )}
  155. disabled={!deleteDisabled}
  156. >
  157. <RemoveConfirm onConfirm={handleRemove} disabled={deleteDisabled}>
  158. <Button priority="danger">{authenticator.removeButton}</Button>
  159. </RemoveConfirm>
  160. </Tooltip>
  161. )}
  162. </AuthenticatorActions>
  163. }
  164. />
  165. <TextBlock>{authenticator.description}</TextBlock>
  166. <AuthenticatorDates>
  167. <AuthenticatorDate label={t('Created at')} date={authenticator.createdAt} />
  168. <AuthenticatorDate label={t('Last used')} date={authenticator.lastUsedAt} />
  169. </AuthenticatorDates>
  170. <U2fEnrolledDetails
  171. isEnrolled={authenticator.isEnrolled}
  172. id={authenticator.id}
  173. devices={authenticator.devices}
  174. onRemoveU2fDevice={handleRemove}
  175. onRenameU2fDevice={handleRename}
  176. />
  177. {authenticator.isEnrolled && authenticator.phone && (
  178. <PhoneWrapper>
  179. {t('Confirmation codes are sent to the following phone number')}:
  180. <Phone>{authenticator.phone}</Phone>
  181. </PhoneWrapper>
  182. )}
  183. <RecoveryCodes
  184. onRegenerateBackupCodes={onRegenerateBackupCodes}
  185. isEnrolled={authenticator.isEnrolled}
  186. codes={authenticator.codes}
  187. />
  188. </SentryDocumentTitle>
  189. );
  190. }
  191. export default AccountSecurityDetails;
  192. const AuthenticatorStatus = styled(CircleIndicator)`
  193. margin-left: ${space(1)};
  194. `;
  195. const AuthenticatorActions = styled('div')`
  196. display: flex;
  197. justify-content: center;
  198. align-items: center;
  199. > * {
  200. margin-left: ${space(1)};
  201. }
  202. `;
  203. const AuthenticatorDates = styled('div')`
  204. display: grid;
  205. gap: ${space(2)};
  206. grid-template-columns: max-content auto;
  207. `;
  208. const DateLabel = styled('span')`
  209. font-weight: ${p => p.theme.fontWeightBold};
  210. `;
  211. const PhoneWrapper = styled('div')`
  212. margin-top: ${space(4)};
  213. `;
  214. const Phone = styled('span')`
  215. font-weight: ${p => p.theme.fontWeightBold};
  216. margin-left: ${space(1)};
  217. `;