Passkey.vue 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. <script setup lang="ts">
  2. import { message } from 'ant-design-vue'
  3. import { startRegistration } from '@simplewebauthn/browser'
  4. import { DeleteOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons-vue'
  5. import dayjs from 'dayjs'
  6. import relativeTime from 'dayjs/plugin/relativeTime'
  7. import { formatDateTime } from '@/lib/helper'
  8. import type { Passkey } from '@/api/passkey'
  9. import passkey from '@/api/passkey'
  10. import ReactiveFromNow from '@/components/ReactiveFromNow/ReactiveFromNow.vue'
  11. import { useUserStore } from '@/pinia'
  12. dayjs.extend(relativeTime)
  13. const user = useUserStore()
  14. const passkeyName = ref('')
  15. const addPasskeyModelOpen = ref(false)
  16. const regLoading = ref(false)
  17. async function registerPasskey() {
  18. regLoading.value = true
  19. try {
  20. const options = await passkey.begin_registration()
  21. const attestationResponse = await startRegistration(options.publicKey)
  22. await passkey.finish_registration(attestationResponse, passkeyName.value)
  23. getList()
  24. message.success($gettext('Register passkey successfully'))
  25. addPasskeyModelOpen.value = false
  26. user.passkeyRawId = attestationResponse.rawId
  27. }
  28. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  29. catch (e: any) {
  30. message.error($gettext(e.message ?? 'Server error'))
  31. }
  32. regLoading.value = false
  33. }
  34. const getListLoading = ref(true)
  35. const data = ref([]) as Ref<Passkey[]>
  36. function getList() {
  37. getListLoading.value = true
  38. passkey.get_list().then(r => {
  39. data.value = r
  40. }).catch((e: { message?: string }) => {
  41. message.error(e?.message ?? $gettext('Server error'))
  42. }).finally(() => {
  43. getListLoading.value = false
  44. })
  45. }
  46. onMounted(() => {
  47. getList()
  48. })
  49. const modifyIdx = ref(-1)
  50. function update(id: number, record: Passkey) {
  51. passkey.update(id, record).then(() => {
  52. getList()
  53. modifyIdx.value = -1
  54. message.success($gettext('Update successfully'))
  55. }).catch((e: { message?: string }) => {
  56. message.error(e?.message ?? $gettext('Server error'))
  57. })
  58. }
  59. function remove(item: Passkey) {
  60. passkey.remove(item.id).then(() => {
  61. getList()
  62. message.success($gettext('Remove successfully'))
  63. // if current passkey is removed, clear it from user store
  64. if (user.passkeyLoginAvailable && user.passkeyRawId === item.raw_id)
  65. user.passkeyRawId = ''
  66. }).catch((e: { message?: string }) => {
  67. message.error(e?.message ?? $gettext('Server error'))
  68. })
  69. }
  70. function addPasskey() {
  71. addPasskeyModelOpen.value = true
  72. passkeyName.value = ''
  73. }
  74. </script>
  75. <template>
  76. <div>
  77. <div>
  78. <h3>
  79. {{ $gettext('Passkey') }}
  80. </h3>
  81. <p>
  82. {{ $gettext('Passkeys are webauthn credentials that validate your identity using touch, '
  83. + 'facial recognition, a device password, or a PIN. '
  84. + 'They can be used as a password replacement or as a 2FA method.') }}
  85. </p>
  86. </div>
  87. <AList
  88. class="mt-4"
  89. bordered
  90. :data-source="data"
  91. >
  92. <template #header>
  93. <div class="flex items-center justify-between">
  94. <div class="font-bold">
  95. {{ $gettext('Your passkeys') }}
  96. </div>
  97. <AButton @click="addPasskey">
  98. {{ $gettext('Add a passkey') }}
  99. </AButton>
  100. </div>
  101. </template>
  102. <template #renderItem="{ item, index }">
  103. <AListItem>
  104. <AListItemMeta>
  105. <template #title>
  106. <div class="flex gap-2">
  107. <KeyOutlined />
  108. <div v-if="index !== modifyIdx">
  109. {{ item.name }}
  110. </div>
  111. <div v-else>
  112. <AInput v-model:value="passkeyName" />
  113. </div>
  114. </div>
  115. </template>
  116. <template #description>
  117. {{ $gettext('Created at') }}: {{ formatDateTime(item.created_at) }} · {{
  118. $gettext('Last used at') }}: <ReactiveFromNow :time="item.last_used_at" />
  119. </template>
  120. </AListItemMeta>
  121. <template #extra>
  122. <div v-if="modifyIdx !== index">
  123. <AButton
  124. type="link"
  125. size="small"
  126. @click="() => modifyIdx = index"
  127. >
  128. <EditOutlined />
  129. </AButton>
  130. <APopconfirm
  131. :title="$gettext('Are you sure to delete this passkey immediately?')"
  132. @confirm="() => remove(item)"
  133. >
  134. <AButton
  135. type="link"
  136. danger
  137. size="small"
  138. >
  139. <DeleteOutlined />
  140. </AButton>
  141. </APopconfirm>
  142. </div>
  143. <div v-else>
  144. <AButton
  145. size="small"
  146. @click="() => update(item.id, { ...item, name: passkeyName })"
  147. >
  148. {{ $gettext('Save') }}
  149. </AButton>
  150. <AButton
  151. type="link"
  152. size="small"
  153. @click="() => {
  154. modifyIdx = -1
  155. passkeyName = item.name
  156. }"
  157. >
  158. {{ $gettext('Cancel') }}
  159. </AButton>
  160. </div>
  161. </template>
  162. </AListItem>
  163. </template>
  164. </AList>
  165. <AModal
  166. v-model:open="addPasskeyModelOpen"
  167. :title="$gettext('Add a passkey')"
  168. centered
  169. :mask="false"
  170. :mask-closable="false"
  171. :closable="false"
  172. :confirm-loading="regLoading"
  173. @ok="registerPasskey"
  174. >
  175. <AForm layout="vertical">
  176. <AFormItem :label="$gettext('Name')">
  177. <AInput v-model:value="passkeyName" />
  178. </AFormItem>
  179. </AForm>
  180. </AModal>
  181. </div>
  182. </template>
  183. <style scoped lang="less">
  184. </style>