PersonalSettingTokenAccess.vue 6.9 KB

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