PersonalSettingTwoFactorAuth.vue 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. <!-- Copyright (C) 2012-2025 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 ({ token }: { token: string }) => {
  112. const data = await removeTwoFactorMethod.send({
  113. methodName: entity.name,
  114. token,
  115. })
  116. if (data?.userCurrentTwoFactorRemoveMethod?.success) {
  117. notify({
  118. id: 'two-factor-method-removed',
  119. type: NotificationTypes.Success,
  120. message: __('Two-factor authentication method was removed.'),
  121. })
  122. }
  123. },
  124. })
  125. }
  126. const lookUpA11yActionLabel = (
  127. entity: ObjectLike,
  128. type: TwoFactorActionTypes,
  129. ) => {
  130. const authenticatorMethod = twoFactorConfigurationMethods.value.find(
  131. (method) => method.name === entity.name,
  132. )
  133. return (
  134. authenticatorMethod?.configurationOptions?.getActionA11yLabel(type) || ''
  135. )
  136. }
  137. const actions = computed<MenuItem[]>(() => [
  138. {
  139. key: 'setup',
  140. label: __('Set up'),
  141. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'setup'),
  142. icon: 'wrench',
  143. show: (entity) => !entity?.configured,
  144. onClick: (entity) => openTwoFactorConfigurationFlyout(entity?.name),
  145. },
  146. {
  147. key: 'edit',
  148. label: __('Edit'),
  149. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'edit'),
  150. icon: 'pencil',
  151. show: (entity) => {
  152. return Boolean(
  153. entity?.configured && entity?.configurationOptions.editable,
  154. )
  155. },
  156. onClick: (entity) => openTwoFactorConfigurationFlyout(entity?.name),
  157. },
  158. {
  159. key: 'setAsDefault',
  160. label: __('Set as default'),
  161. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'default'),
  162. icon: 'arrow-repeat',
  163. show: (entity) => Boolean(entity?.configured && !entity?.default),
  164. onClick: (entity) => submitTwoFactorDefaultMethod(entity),
  165. },
  166. {
  167. key: 'remove',
  168. label: __('Remove'),
  169. ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'remove'),
  170. icon: 'trash3',
  171. variant: 'danger',
  172. show: (entity) => Boolean(entity?.configured),
  173. onClick: (entity) => submitTwoFactorMethodRemoval(entity),
  174. },
  175. ])
  176. </script>
  177. <template>
  178. <LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
  179. <div class="flex flex-col gap-2.5">
  180. <div>
  181. <CommonLabel class="mb-1.5">{{ $t('Available methods') }}</CommonLabel>
  182. <div class="flex flex-col rounded-lg bg-blue-200 p-1 dark:bg-gray-700">
  183. <div
  184. v-for="twoFactorMethod in twoFactorConfigurationMethods"
  185. :key="twoFactorMethod.name"
  186. class="flex items-start gap-1.5 p-2.5"
  187. >
  188. <CommonIcon
  189. class="text-stone-200 dark:text-neutral-500"
  190. :name="twoFactorMethod.icon"
  191. size="small"
  192. />
  193. <div class="flex grow flex-col gap-0.5">
  194. <div class="flex grow gap-1.5">
  195. <CommonLabel class="text-black dark:text-white"
  196. >{{ $t(twoFactorMethod.label) }}
  197. </CommonLabel>
  198. <CommonBadge
  199. v-if="twoFactorMethod.configured"
  200. variant="success"
  201. >
  202. {{ $t('Active') }}
  203. </CommonBadge>
  204. <CommonBadge v-if="twoFactorMethod.default" variant="info"
  205. >{{ $t('Default') }}
  206. </CommonBadge>
  207. </div>
  208. <CommonLabel
  209. v-if="twoFactorMethod.description"
  210. class="text-stone-200 dark:text-neutral-500"
  211. size="small"
  212. >{{ $t(twoFactorMethod.description) }}
  213. </CommonLabel>
  214. </div>
  215. <CommonActionMenu
  216. :entity="twoFactorMethod"
  217. :custom-menu-button-label="
  218. twoFactorMethod.configurationOptions?.actionButtonA11yLabel
  219. "
  220. :actions="actions"
  221. />
  222. </div>
  223. </div>
  224. </div>
  225. <template v-if="hasConfiguredMethods && hasEnabledRecoveryCodes">
  226. <CommonLabel
  227. >{{
  228. $t(
  229. 'Recovery codes can be used to access your account in the event you lose access to other two-factor authentication methods.',
  230. )
  231. }}
  232. </CommonLabel>
  233. <CommonLabel v-if="hasRecoveryCodes"
  234. >{{
  235. $t(
  236. "If you lose your recovery codes it's possible to generate new ones. This action is going to invalidate previous recovery codes.",
  237. )
  238. }}
  239. </CommonLabel>
  240. <div class="flex justify-end">
  241. <CommonButton
  242. variant="submit"
  243. type="submit"
  244. size="medium"
  245. @click="openTwoFactorConfigurationFlyout('recovery_codes')"
  246. >
  247. {{
  248. hasRecoveryCodes
  249. ? $t('Regenerate Recovery Codes')
  250. : $t('Generate Recovery Codes')
  251. }}
  252. </CommonButton>
  253. </div>
  254. </template>
  255. </div>
  256. </LayoutContent>
  257. </template>