PersonalSettingTwoFactorAuth.vue 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { computed, watch } from 'vue'
  4. import { useRouter } from 'vue-router'
  5. import {
  6. NotificationTypes,
  7. useNotifications,
  8. } from '#shared/components/CommonNotifications/index.ts'
  9. import { useApplicationConfigTwoFactor } from '#shared/composables/authentication/useApplicationConfigTwoFactor.ts'
  10. import type { TwoFactorActionTypes } from '#shared/entities/two-factor/types.ts'
  11. import { useUserCurrentTwoFactorRemoveMethodMutation } from '#shared/entities/user/current/graphql/mutations/two-factor/userCurrentTwoFactorRemoveMethod.api.ts'
  12. import { useUserCurrentTwoFactorSetDefaultMethodMutation } from '#shared/entities/user/current/graphql/mutations/two-factor/userCurrentTwoFactorSetDefaultMethod.api.ts'
  13. import { ErrorRouteType, redirectErrorRoute } from '#shared/router/error.ts'
  14. import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
  15. import { useSessionStore } from '#shared/stores/session.ts'
  16. import { ErrorStatusCodes } from '#shared/types/error.ts'
  17. import type { ObjectLike } from '#shared/types/utils.ts'
  18. import CommonActionMenu from '#desktop/components/CommonActionMenu/CommonActionMenu.vue'
  19. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  20. import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
  21. import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
  22. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  23. import type { TwoFactorConfigurationType } from '#desktop/components/TwoFactor/types.ts'
  24. import { useConfigurationTwoFactor } from '#desktop/entities/two-factor-configuration/composables/useConfigurationTwoFactor.ts'
  25. import { useBreadcrumb } from '../composables/useBreadcrumb.ts'
  26. defineOptions({
  27. beforeRouteEnter() {
  28. const { hasEnabledMethods } = useApplicationConfigTwoFactor()
  29. if (!hasEnabledMethods.value)
  30. return redirectErrorRoute({
  31. type: ErrorRouteType.AuthenticatedError,
  32. title: __('Forbidden'),
  33. message: __('There are no enabled two-factor authentication methods.'),
  34. statusCode: ErrorStatusCodes.Forbidden,
  35. })
  36. return true
  37. },
  38. })
  39. const session = useSessionStore()
  40. const router = useRouter()
  41. const { notify } = useNotifications()
  42. const {
  43. hasEnabledMethods,
  44. hasEnabledRecoveryCodes,
  45. hasConfiguredMethods,
  46. hasRecoveryCodes,
  47. twoFactorConfigurationMethods,
  48. } = useConfigurationTwoFactor()
  49. watch(hasEnabledMethods, (newValue) => {
  50. if (newValue) return
  51. router.replace(
  52. redirectErrorRoute({
  53. type: ErrorRouteType.AuthenticatedError,
  54. title: __('Forbidden'),
  55. message: __('There are no enabled two-factor authentication methods.'),
  56. statusCode: ErrorStatusCodes.Forbidden,
  57. }),
  58. )
  59. })
  60. const { breadcrumbItems } = useBreadcrumb(__('Two-factor Authentication'))
  61. const twoFactorConfigurationFlyout = useFlyout({
  62. name: 'two-factor-flyout',
  63. component: () =>
  64. import('#desktop/components/TwoFactor/TwoFactorConfigurationFlyout.vue'),
  65. })
  66. const openTwoFactorConfigurationFlyout = async (
  67. type: TwoFactorConfigurationType,
  68. ) => {
  69. return twoFactorConfigurationFlyout.open({
  70. type,
  71. })
  72. }
  73. const setDefaultTwoFactorMethod = new MutationHandler(
  74. useUserCurrentTwoFactorSetDefaultMethodMutation(),
  75. {
  76. errorNotificationMessage: __(
  77. 'Could not set two-factor authentication method as default',
  78. ),
  79. },
  80. )
  81. const submitTwoFactorDefaultMethod = (entity?: ObjectLike) => {
  82. if (!entity) return
  83. setDefaultTwoFactorMethod
  84. .send({
  85. methodName: entity.name,
  86. })
  87. .then(() => {
  88. session.setUserPreference('two_factor_authentication', {
  89. ...(session.user?.preferences?.two_factor_authentication || {}),
  90. default: entity.name,
  91. })
  92. notify({
  93. id: 'two-factor-method-set-default',
  94. type: NotificationTypes.Success,
  95. message: __('Two-factor authentication method was set as default.'),
  96. })
  97. })
  98. }
  99. const removeTwoFactorMethod = new MutationHandler(
  100. useUserCurrentTwoFactorRemoveMethodMutation(),
  101. {
  102. errorNotificationMessage: __(
  103. 'Could not remove two-factor authentication method.',
  104. ),
  105. },
  106. )
  107. const submitTwoFactorMethodRemoval = async (entity?: ObjectLike) => {
  108. if (!entity) return
  109. return twoFactorConfigurationFlyout.open({
  110. type: 'removal_confirmation',
  111. successCallback: async () => {
  112. const data = await removeTwoFactorMethod.send({
  113. methodName: entity.name,
  114. })
  115. if (data?.userCurrentTwoFactorRemoveMethod?.success) {
  116. notify({
  117. id: 'two-factor-method-removed',
  118. type: NotificationTypes.Success,
  119. message: __('Two-factor authentication method was removed.'),
  120. })
  121. }
  122. },
  123. })
  124. }
  125. const lookUpA11yActionLabel = (
  126. entity: ObjectLike,
  127. type: TwoFactorActionTypes,
  128. ) => {
  129. const authenticatorMethod = twoFactorConfigurationMethods.value.find(
  130. (method) => method.name === entity.name,
  131. )
  132. return (
  133. authenticatorMethod?.configurationOptions?.getActionA11yLabel(type) || ''
  134. )
  135. }
  136. const actions = computed<MenuItem[]>(() => [
  137. {
  138. key: 'setup',
  139. label: __('Set up'),
  140. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'setup'),
  141. icon: 'wrench',
  142. show: (entity) => !entity?.configured,
  143. onClick: (entity) => openTwoFactorConfigurationFlyout(entity?.name),
  144. },
  145. {
  146. key: 'edit',
  147. label: __('Edit'),
  148. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'edit'),
  149. icon: 'pencil',
  150. show: (entity) => {
  151. return Boolean(
  152. entity?.configured && entity?.configurationOptions.editable,
  153. )
  154. },
  155. onClick: (entity) => openTwoFactorConfigurationFlyout(entity?.name),
  156. },
  157. {
  158. key: 'setAsDefault',
  159. label: __('Set as default'),
  160. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'default'),
  161. icon: 'arrow-repeat',
  162. show: (entity) => Boolean(entity?.configured && !entity?.default),
  163. onClick: (entity) => submitTwoFactorDefaultMethod(entity),
  164. },
  165. {
  166. key: 'remove',
  167. label: __('Remove'),
  168. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'remove'),
  169. icon: 'trash3',
  170. variant: 'danger',
  171. show: (entity) => Boolean(entity?.configured),
  172. onClick: (entity) => submitTwoFactorMethodRemoval(entity),
  173. },
  174. ])
  175. </script>
  176. <template>
  177. <LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
  178. <div class="flex flex-col gap-2.5">
  179. <div>
  180. <CommonLabel class="mb-1.5">{{ $t('Available methods') }}</CommonLabel>
  181. <div class="flex flex-col rounded-lg bg-blue-200 p-1 dark:bg-gray-700">
  182. <div
  183. v-for="twoFactorMethod in twoFactorConfigurationMethods"
  184. :key="twoFactorMethod.name"
  185. class="flex items-start gap-1.5 p-2.5"
  186. >
  187. <CommonIcon
  188. class="text-stone-200 dark:text-neutral-500"
  189. :name="twoFactorMethod.icon"
  190. size="small"
  191. />
  192. <div class="flex grow flex-col gap-0.5">
  193. <div class="flex grow gap-1.5">
  194. <CommonLabel class="text-black dark:text-white"
  195. >{{ $t(twoFactorMethod.label) }}
  196. </CommonLabel>
  197. <CommonBadge
  198. v-if="twoFactorMethod.configured"
  199. variant="success"
  200. >
  201. {{ $t('Active') }}
  202. </CommonBadge>
  203. <CommonBadge v-if="twoFactorMethod.default" variant="info"
  204. >{{ $t('Default') }}
  205. </CommonBadge>
  206. </div>
  207. <CommonLabel
  208. v-if="twoFactorMethod.description"
  209. class="text-stone-200 dark:text-neutral-500"
  210. size="small"
  211. >{{ $t(twoFactorMethod.description) }}
  212. </CommonLabel>
  213. </div>
  214. <CommonActionMenu
  215. :entity="twoFactorMethod"
  216. :custom-menu-button-label="
  217. twoFactorMethod.configurationOptions?.actionButtonA11yLabel
  218. "
  219. :actions="actions"
  220. />
  221. </div>
  222. </div>
  223. </div>
  224. <template v-if="hasConfiguredMethods && hasEnabledRecoveryCodes">
  225. <CommonLabel
  226. >{{
  227. $t(
  228. 'Recovery codes can be used to access your account in the event you lose access to other two-factor authentication methods.',
  229. )
  230. }}
  231. </CommonLabel>
  232. <CommonLabel v-if="hasRecoveryCodes"
  233. >{{
  234. $t(
  235. "If you lose your recovery codes it's possible to generate new ones. This action is going to invalidate previous recovery codes.",
  236. )
  237. }}
  238. </CommonLabel>
  239. <div class="flex justify-end">
  240. <CommonButton
  241. variant="submit"
  242. type="submit"
  243. size="medium"
  244. @click="openTwoFactorConfigurationFlyout('recovery_codes')"
  245. >
  246. {{
  247. hasRecoveryCodes
  248. ? $t('Regenerate Recovery Codes')
  249. : $t('Generate Recovery Codes')
  250. }}
  251. </CommonButton>
  252. </div>
  253. </template>
  254. </div>
  255. </LayoutContent>
  256. </template>