PersonalSettingTokenAccess.vue 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { computed } from 'vue'
  4. import { NotificationTypes } from '#shared/components/CommonNotifications/types.ts'
  5. import { useNotifications } from '#shared/components/CommonNotifications/useNotifications.ts'
  6. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  7. import { useUserCurrentAccessTokenDeleteMutation } from '#shared/entities/user/current/graphql/mutations/userCurrentAccessTokenDelete.api.ts'
  8. import { useUserCurrentAccessTokenListQuery } from '#shared/entities/user/current/graphql/queries/userCurrentAcessTokenList.api.ts'
  9. import type {
  10. Token,
  11. UserCurrentAccessTokenUpdatesSubscription,
  12. UserCurrentAccessTokenUpdatesSubscriptionVariables,
  13. UserCurrentAccessTokenListQuery,
  14. } from '#shared/graphql/types.ts'
  15. import { i18n } from '#shared/i18n/index.ts'
  16. import { ErrorRouteType, redirectErrorRoute } from '#shared/router/error.ts'
  17. import MutationHandler from '#shared/server/apollo/handler/MutationHandler.ts'
  18. import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
  19. import { ErrorStatusCodes } from '#shared/types/error.ts'
  20. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  21. import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
  22. import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
  23. import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
  24. import CommonSimpleTable from '#desktop/components/CommonTable/CommonSimpleTable.vue'
  25. import type {
  26. TableSimpleHeader,
  27. TableItem,
  28. } from '#desktop/components/CommonTable/types.ts'
  29. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  30. import { useCheckTokenAccess } from '../composables/permission/useCheckTokenAccess.ts'
  31. import { useBreadcrumb } from '../composables/useBreadcrumb.ts'
  32. import { UserCurrentAccessTokenUpdatesDocument } from '../graphql/subscriptions/userCurrentAccessTokenUpdates.api.ts'
  33. defineOptions({
  34. beforeRouteEnter() {
  35. const { canUseAccessToken } = useCheckTokenAccess()
  36. if (!canUseAccessToken.value)
  37. return redirectErrorRoute({
  38. type: ErrorRouteType.AuthenticatedError,
  39. title: __('Forbidden'),
  40. message: __(
  41. 'Token-based API access has been disabled by the administrator.',
  42. ),
  43. statusCode: ErrorStatusCodes.Forbidden,
  44. })
  45. return true
  46. },
  47. })
  48. const { breadcrumbItems } = useBreadcrumb(__('Token Access'))
  49. const newAccessTokenFlyout = useFlyout({
  50. name: 'new-access-token',
  51. component: () =>
  52. import('../components/PersonalSettingNewAccessTokenFlyout.vue'),
  53. })
  54. const accessTokenListQuery = new QueryHandler(
  55. useUserCurrentAccessTokenListQuery(),
  56. )
  57. const accessTokenListQueryResult = accessTokenListQuery.result()
  58. const accessTokenListLoading = accessTokenListQuery.loading()
  59. accessTokenListQuery.subscribeToMore<
  60. UserCurrentAccessTokenUpdatesSubscriptionVariables,
  61. UserCurrentAccessTokenUpdatesSubscription
  62. >({
  63. document: UserCurrentAccessTokenUpdatesDocument,
  64. updateQuery: (prev, { subscriptionData }) => {
  65. if (!subscriptionData.data?.userCurrentAccessTokenUpdates.tokens) {
  66. return null as unknown as UserCurrentAccessTokenListQuery
  67. }
  68. return {
  69. userCurrentAccessTokenList:
  70. subscriptionData.data.userCurrentAccessTokenUpdates.tokens,
  71. }
  72. },
  73. })
  74. const tableHeaders: TableSimpleHeader[] = [
  75. {
  76. key: 'name',
  77. label: __('Name'),
  78. truncate: true,
  79. },
  80. {
  81. key: 'permissions',
  82. label: __('Permissions'),
  83. truncate: true,
  84. },
  85. {
  86. key: 'createdAt',
  87. label: __('Created'),
  88. type: 'timestamp',
  89. },
  90. {
  91. key: 'expiresAt',
  92. label: __('Expires'),
  93. type: 'timestamp',
  94. },
  95. {
  96. key: 'lastUsedAt',
  97. label: __('Last Used'),
  98. type: 'timestamp',
  99. },
  100. ]
  101. const { notify } = useNotifications()
  102. const { waitForVariantConfirmation } = useConfirmation()
  103. const deleteDevice = (accessToken: Token) => {
  104. const accessTokenDeleteMutation = new MutationHandler(
  105. useUserCurrentAccessTokenDeleteMutation(() => ({
  106. variables: {
  107. tokenId: accessToken.id,
  108. },
  109. update(cache) {
  110. cache.evict({ id: cache.identify(accessToken) })
  111. cache.gc()
  112. },
  113. })),
  114. {
  115. errorNotificationMessage: __(
  116. 'The personal access token could not be deleted.',
  117. ),
  118. },
  119. )
  120. accessTokenDeleteMutation.send().then(() => {
  121. notify({
  122. id: 'personal-access-token-removed',
  123. type: NotificationTypes.Success,
  124. message: __('Personal access token has been deleted.'),
  125. })
  126. })
  127. }
  128. const confirmDeleteDevice = async (accessToken: Token) => {
  129. const confirmed = await waitForVariantConfirmation('delete')
  130. if (confirmed) deleteDevice(accessToken)
  131. }
  132. const tableActions: MenuItem[] = [
  133. {
  134. key: 'delete',
  135. label: __('Delete this access token'),
  136. icon: 'trash3',
  137. variant: 'danger',
  138. onClick: (data) => {
  139. confirmDeleteDevice(data as Token)
  140. },
  141. },
  142. ]
  143. const currentAccessTokens = computed<TableItem[]>(() => {
  144. return (
  145. accessTokenListQueryResult.value?.userCurrentAccessTokenList || []
  146. ).map((accessToken) => {
  147. return {
  148. ...accessToken,
  149. permissions: accessToken.preferences?.permission?.join(', ') || '',
  150. }
  151. })
  152. })
  153. const currentAccessTokenPresent = computed(
  154. () => currentAccessTokens.value.length > 0,
  155. )
  156. const helpText = computed(() => [
  157. i18n.t(
  158. 'You can generate a personal access token for each application you use that needs access to the Zammad API.',
  159. ),
  160. i18n.t("Pick a name for the application, and we'll give you a unique token."),
  161. ])
  162. </script>
  163. <template>
  164. <LayoutContent
  165. :help-text="helpText"
  166. :show-inline-help="!currentAccessTokenPresent && !accessTokenListLoading"
  167. :breadcrumb-items="breadcrumbItems"
  168. width="narrow"
  169. >
  170. <template #headerRight>
  171. <div class="flex flex-row gap-2">
  172. <CommonButton
  173. prefix-icon="key"
  174. variant="primary"
  175. size="medium"
  176. @click="newAccessTokenFlyout.open()"
  177. >
  178. {{ $t('New Personal Access Token') }}
  179. </CommonButton>
  180. </div>
  181. </template>
  182. <CommonLoader :loading="accessTokenListLoading">
  183. <div class="mb-4">
  184. <CommonSimpleTable
  185. :headers="tableHeaders"
  186. :items="currentAccessTokens"
  187. :actions="tableActions"
  188. :caption="$t('Personal Access Tokens')"
  189. class="min-w-150"
  190. >
  191. <template #item-suffix-name="{ item }">
  192. <CommonBadge
  193. v-if="item.current"
  194. size="medium"
  195. variant="info"
  196. class="ltr:ml-2 rtl:mr-2"
  197. >{{ $t('This device') }}
  198. </CommonBadge>
  199. </template>
  200. </CommonSimpleTable>
  201. </div>
  202. </CommonLoader>
  203. </LayoutContent>
  204. </template>