accountSecurityDetails.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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, isPending: isRemoveLoading} = useMutation({
  65. mutationFn: ({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. onSuccess: (_, {device}) => {
  74. const deviceName = device ? device.name : t('Authenticator');
  75. addSuccessMessage(t('%s has been removed', deviceName));
  76. },
  77. onError: (_, {device}) => {
  78. const deviceName = device ? device.name : t('Authenticator');
  79. addErrorMessage(t('Error removing %s', deviceName));
  80. },
  81. onSettled: () => {
  82. queryClient.invalidateQueries({queryKey: getAuthenticatorQueryKey(authId)});
  83. },
  84. });
  85. const {mutate: rename, isPending: isRenameLoading} = useMutation({
  86. mutationFn: ({
  87. id,
  88. device,
  89. name,
  90. }: {
  91. device: AuthenticatorDevice;
  92. id: string;
  93. name: string;
  94. }) => {
  95. return api.requestPromise(`${ENDPOINT}${id}/${device.key_handle}/`, {
  96. method: 'PUT',
  97. data: {
  98. name,
  99. },
  100. });
  101. },
  102. onSuccess: () => {
  103. navigate(`/settings/account/security/mfa/${authId}`);
  104. addSuccessMessage(t('Device was renamed'));
  105. },
  106. onError: () => {
  107. addErrorMessage(t('Error renaming the device'));
  108. },
  109. onSettled: () => {
  110. queryClient.invalidateQueries({queryKey: getAuthenticatorQueryKey(authId)});
  111. },
  112. });
  113. const handleRemove = (device?: AuthenticatorDevice) => {
  114. if (!authenticator?.authId) {
  115. return;
  116. }
  117. remove({id: authenticator.authId, device});
  118. };
  119. const handleRename = (device: AuthenticatorDevice, deviceName: string) => {
  120. if (!authenticator?.authId) {
  121. return;
  122. }
  123. rename({id: authenticator.authId, device, name: deviceName});
  124. };
  125. if (isAuthenticatorPending || isRemoveLoading || isRenameLoading) {
  126. return <LoadingIndicator />;
  127. }
  128. if (isError) {
  129. return <LoadingError onRetry={refetch} />;
  130. }
  131. return (
  132. <SentryDocumentTitle title={t('Security')}>
  133. <SettingsPageHeader
  134. title={
  135. <Fragment>
  136. <span>{authenticator.name}</span>
  137. <AuthenticatorStatus
  138. data-test-id={`auth-status-${
  139. authenticator.isEnrolled ? 'enabled' : 'disabled'
  140. }`}
  141. enabled={authenticator.isEnrolled}
  142. />
  143. </Fragment>
  144. }
  145. action={
  146. <AuthenticatorActions>
  147. {authenticator.isEnrolled && authenticator.allowRotationInPlace && (
  148. <LinkButton
  149. to={`/settings/account/security/mfa/${authenticator.id}/enroll/`}
  150. >
  151. {t('Rotate Secret Key')}
  152. </LinkButton>
  153. )}
  154. {authenticator.isEnrolled && authenticator.removeButton && (
  155. <Tooltip
  156. title={t(
  157. "Two-factor authentication is required for at least one organization you're a member of."
  158. )}
  159. disabled={!deleteDisabled}
  160. >
  161. <RemoveConfirm onConfirm={handleRemove} disabled={deleteDisabled}>
  162. <Button priority="danger">{authenticator.removeButton}</Button>
  163. </RemoveConfirm>
  164. </Tooltip>
  165. )}
  166. </AuthenticatorActions>
  167. }
  168. />
  169. <TextBlock>{authenticator.description}</TextBlock>
  170. <AuthenticatorDates>
  171. <AuthenticatorDate label={t('Created at')} date={authenticator.createdAt} />
  172. <AuthenticatorDate label={t('Last used')} date={authenticator.lastUsedAt} />
  173. </AuthenticatorDates>
  174. <U2fEnrolledDetails
  175. isEnrolled={authenticator.isEnrolled}
  176. id={authenticator.id}
  177. devices={authenticator.devices}
  178. onRemoveU2fDevice={handleRemove}
  179. onRenameU2fDevice={handleRename}
  180. />
  181. {authenticator.isEnrolled && authenticator.phone && (
  182. <PhoneWrapper>
  183. {t('Confirmation codes are sent to the following phone number')}:
  184. <Phone>{authenticator.phone}</Phone>
  185. </PhoneWrapper>
  186. )}
  187. <RecoveryCodes
  188. onRegenerateBackupCodes={onRegenerateBackupCodes}
  189. isEnrolled={authenticator.isEnrolled}
  190. codes={authenticator.codes}
  191. />
  192. </SentryDocumentTitle>
  193. );
  194. }
  195. export default AccountSecurityDetails;
  196. const AuthenticatorStatus = styled(CircleIndicator)`
  197. margin-left: ${space(1)};
  198. `;
  199. const AuthenticatorActions = styled('div')`
  200. display: flex;
  201. justify-content: center;
  202. align-items: center;
  203. > * {
  204. margin-left: ${space(1)};
  205. }
  206. `;
  207. const AuthenticatorDates = styled('div')`
  208. display: grid;
  209. gap: ${space(2)};
  210. grid-template-columns: max-content auto;
  211. `;
  212. const DateLabel = styled('span')`
  213. font-weight: ${p => p.theme.fontWeightBold};
  214. `;
  215. const PhoneWrapper = styled('div')`
  216. margin-top: ${space(4)};
  217. `;
  218. const Phone = styled('span')`
  219. font-weight: ${p => p.theme.fontWeightBold};
  220. margin-left: ${space(1)};
  221. `;