PersonalSettingLinkedAccounts.vue 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { storeToRefs } from 'pinia'
  4. import { computed, ref } from 'vue'
  5. import {
  6. NotificationTypes,
  7. useNotifications,
  8. } from '#shared/components/CommonNotifications/index.ts'
  9. import { useThirdPartyAuthentication } from '#shared/composables/authentication/useThirdPartyAuthentication.ts'
  10. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  11. import useFingerprint from '#shared/composables/useFingerprint.ts'
  12. import {
  13. type Authorization,
  14. EnumAuthenticationProvider,
  15. } from '#shared/graphql/types.ts'
  16. import { i18n } from '#shared/i18n.ts'
  17. import { ErrorRouteType, redirectErrorRoute } from '#shared/router/error.ts'
  18. import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
  19. import { useSessionStore } from '#shared/stores/session.ts'
  20. import { ErrorStatusCodes } from '#shared/types/error.ts'
  21. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  22. import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
  23. import CommonSimpleTable from '#desktop/components/CommonSimpleTable/CommonSimpleTable.vue'
  24. import type {
  25. TableHeader,
  26. TableItem,
  27. } from '#desktop/components/CommonSimpleTable/types.ts'
  28. import CommonThirdPartyAuthenticationButton from '#desktop/components/CommonThirdPartyAuthenticationButton/CommonThirdPartyAuthenticationButton.vue'
  29. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  30. import { useBreadcrumb } from '#desktop/pages/personal-setting/composables/useBreadcrumb.ts'
  31. import { useUserCurrentRemoveLinkedAccountMutation } from '#desktop/pages/personal-setting/graphql/mutations/userCurrentLinkedAccount.api.ts'
  32. import type { LinkedAccountTableItem } from '#desktop/pages/personal-setting/types/linked-accounts.ts'
  33. defineOptions({
  34. beforeRouteEnter() {
  35. const { hasEnabledProviders } = useThirdPartyAuthentication()
  36. if (!hasEnabledProviders.value)
  37. return redirectErrorRoute({
  38. type: ErrorRouteType.AuthenticatedError,
  39. title: __('Forbidden'),
  40. message: __(
  41. 'There are no enabled third-party authentication providers.',
  42. ),
  43. statusCode: ErrorStatusCodes.Forbidden,
  44. })
  45. return true
  46. },
  47. })
  48. const { notify } = useNotifications()
  49. const { breadcrumbItems } = useBreadcrumb(__('Linked Accounts'))
  50. const { user } = storeToRefs(useSessionStore())
  51. const { enabledProviders } = useThirdPartyAuthentication()
  52. const { fingerprint } = useFingerprint()
  53. const providersLookup = computed(() => {
  54. if (!user.value?.authorizations) return []
  55. const { authorizations } = user.value
  56. return enabledProviders.value.map((enabledProvider) => {
  57. const configuredProvider = authorizations.find(
  58. ({ provider }) => provider === enabledProvider.name,
  59. )
  60. return {
  61. ...enabledProvider,
  62. uid: configuredProvider?.uid,
  63. username: configuredProvider?.username || configuredProvider?.uid,
  64. authorizationId: configuredProvider?.id,
  65. }
  66. })
  67. })
  68. const tableHeaders: TableHeader[] = [
  69. {
  70. key: 'application',
  71. label: __('Application'),
  72. },
  73. {
  74. key: 'username',
  75. label: __('Username'),
  76. truncate: true,
  77. },
  78. ]
  79. const tableItems = computed<TableItem[]>(() =>
  80. providersLookup.value.map((provider, index) => ({
  81. id: `${index}-${provider.name}`,
  82. application: provider.label,
  83. ...provider,
  84. })),
  85. )
  86. const loading = ref(false)
  87. const unlinkMutation = async (
  88. authId: string,
  89. authProvider: EnumAuthenticationProvider,
  90. uid: string,
  91. ) => {
  92. return new MutationHandler(
  93. useUserCurrentRemoveLinkedAccountMutation(() => ({
  94. update(cache) {
  95. if (user.value === null) return
  96. // Evict authorization cache to align in-memory cache
  97. const normalizedId = cache.identify({
  98. authId,
  99. __typename: 'Authorization',
  100. })
  101. cache.evict({ id: normalizedId })
  102. // Identify current user cache and update authorizations field to align in-memory cache
  103. cache.modify({
  104. id: cache.identify(user.value),
  105. fields: {
  106. authorizations(existingAuthorizations, { readField }) {
  107. return existingAuthorizations.filter(
  108. (auth: Authorization) => readField('id', auth) !== authId,
  109. )
  110. },
  111. },
  112. })
  113. cache.gc()
  114. },
  115. })),
  116. ).send({
  117. provider: authProvider,
  118. uid,
  119. })
  120. }
  121. const { waitForVariantConfirmation } = useConfirmation()
  122. const unlinkAccount = async (providerTableItem: LinkedAccountTableItem) => {
  123. const confirmed = await waitForVariantConfirmation('delete')
  124. if (!confirmed) return
  125. try {
  126. loading.value = true
  127. const response = await unlinkMutation(
  128. providerTableItem.authorizationId,
  129. providerTableItem.name,
  130. providerTableItem.uid,
  131. )
  132. if (!response?.userCurrentRemoveLinkedAccount) return
  133. const { success } = response.userCurrentRemoveLinkedAccount
  134. if (success)
  135. notify({
  136. id: 'linked-account-removed',
  137. type: NotificationTypes.Success,
  138. message: __('The account link was successfully removed!'),
  139. })
  140. } finally {
  141. loading.value = false
  142. }
  143. }
  144. const tableActions = computed((): MenuItem[] => [
  145. {
  146. key: 'delete',
  147. icon: 'trash3',
  148. variant: 'danger',
  149. ariaLabel: (provider) =>
  150. i18n.t('Remove account link on %s', provider?.application),
  151. show: (provider) => !!provider?.username,
  152. onClick: (provider) => unlinkAccount(provider as LinkedAccountTableItem),
  153. },
  154. {
  155. key: 'setup',
  156. icon: 'plus-square-fill',
  157. variant: 'secondary',
  158. ariaLabel: (provider) =>
  159. i18n.t('Link account on %s', provider?.application),
  160. show: (provider) => !provider?.username,
  161. },
  162. ])
  163. </script>
  164. <template>
  165. <LayoutContent :breadcrumb-items="breadcrumbItems" width="narrow">
  166. <CommonSimpleTable
  167. :headers="tableHeaders"
  168. :items="tableItems"
  169. :actions="tableActions"
  170. >
  171. <template #actions="{ actions, item }">
  172. <div class="flex items-center justify-center">
  173. <template v-for="action in actions" :key="action.key">
  174. <CommonThirdPartyAuthenticationButton
  175. v-if="action.key === 'setup' && action.show?.(item)"
  176. button-class="flex"
  177. :button-icon="action.icon"
  178. :disabled="loading"
  179. button-size="medium"
  180. :button-label="(action?.ariaLabel as Function)(item)"
  181. :url="`${item?.url}?fingerprint=${fingerprint}`"
  182. />
  183. <CommonButton
  184. v-else-if="action.onClick && action.show?.(item)"
  185. :icon="action.icon"
  186. :disabled="loading"
  187. :class="{ '!bg-transparent': action.variant === 'danger' }"
  188. size="medium"
  189. :variant="action.variant"
  190. :aria-label="(action?.ariaLabel as Function)(item)"
  191. @click="action.onClick?.(item)"
  192. />
  193. </template>
  194. </div>
  195. </template>
  196. </CommonSimpleTable>
  197. </LayoutContent>
  198. </template>