@adminforth/two-factors-auth 3.1.5 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,421 +1,342 @@
1
1
  <template>
2
- <div class="text-3xl text-lightBreadcrumbsText dark:text-darkBreadcrumbsText font-semibold max-w-2xl mr-6 flex-col justify-center items-center">
3
- <p class="flex items-start justify-start leading-none">{{$t('Passkeys')}}</p>
4
- <div class="flex flex-col items-end">
5
- <Table
6
- class="mt-4 w-full"
7
- :columns="[
8
- { label: 'Passkey name', fieldName: 'name' },
9
- { label: 'Last used', fieldName: 'last_used_at' },
10
- { label: 'Actions', fieldName: 'actions'}
11
- ]"
12
- :data="passkeys"
2
+ <div class="af-passkeys flex flex-col justify-center mr-6 md:mr-12">
3
+ <h2 class="af-passkeys-title flex items-start justify-start leading-none text-gray-800 dark:text-gray-50 text-3xl font-semibold">{{$t('Passkeys')}}</h2>
4
+ <p class="af-passkeys-subtitle text-sm mt-3">Manage your passwordless authentication methods</p>
5
+
6
+ <div class="af-passkeys-grid mt-6 flex flex-wrap gap-4">
7
+ <div
8
+ v-for="item in passkeys"
9
+ :key="item.id"
10
+ class="af-passkey-card flex flex-col w-full h-42 lg:w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm"
13
11
  >
14
- <template #cell:name="{item}">
15
- <div class="flex items-center justify-start space-x-2">
16
- <img class="max-h-6 max-w-6" :src="coreStore.theme === 'light' ? item.light_icon : item.dark_icon" />
17
- <p>{{ item.name }}</p>
12
+ <div class="af-passkey-card-header flex items-center gap-3 mb-3">
13
+ <div class="af-passkey-card-icon w-10 h-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-lg shrink-0">
14
+ <img class="max-h-6 max-w-6" :src="coreStore.theme === 'light' ? item.light_icon : item.dark_icon" />
15
+ </div>
16
+ <div class="af-passkey-card-info flex-1 min-w-0">
17
+ <p class="af-passkey-card-name font-semibold text-gray-900 dark:text-white truncate" :title="item.name">{{ item.name }}</p>
18
+ <p class="af-passkey-card-type text-xs text-gray-500 dark:text-gray-400">{{$t('Synced passkey')}}</p>
19
+ </div>
20
+ <span class="af-passkey-card-badge shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
21
+ {{$t('Active')}}
22
+ </span>
18
23
  </div>
19
- </template>
20
- <template #cell:actions="{item}">
21
- <div class="flex items-center justify-start space-x-2">
22
- <Dialog
24
+
25
+ <div class="af-passkey-card-last-used text-xs text-gray-500 dark:text-gray-400 mb-4">
26
+ <span class="af-passkey-card-last-used-label font-medium">{{$t('Last used')}}:</span>
27
+ <span v-if="item.last_used_at" class="af-passkey-card-last-used-date ml-1">{{ formatDateTime(item.last_used_at) }}</span>
28
+ <span v-else class="af-passkey-card-last-used-never ml-1 italic">{{$t('Never')}}</span>
29
+ </div>
30
+
31
+ <div class="af-passkey-card-actions grid grid-cols-2 gap-2 mt-auto">
32
+ <Dialog
23
33
  class="edit-passkey-confirmation-dialog w-96"
24
34
  :buttons="[
25
- { label: 'Save', options: { class: 'dialog-save-button' }, onclick: (dialog) => { renamePasskey(item.id, passkeysNewName); dialog.hide(); } },
26
- { label: 'Cancel', options: { class: 'dialog-cancel-button' }, onclick: (dialog) => {passkeysNewName = ''; dialog.hide();} },
35
+ { label: 'Cancel', options: { class: 'dialog-cancel-button w-full' }, onclick: (dialog) => { passkeysNewName = ''; dialog.hide(); } },
36
+ { label: 'Save', options: { class: 'dialog-save-button w-full' }, onclick: (dialog) => { renamePasskey(item.id, passkeysNewName); dialog.hide(); } },
27
37
  ]"
28
38
  header="Edit Passkey"
29
39
  >
30
40
  <template #trigger>
31
- <Tooltip>
32
- <div
33
- @click="passkeysNewName = ''"
34
- class="w-7 h-7 flex items-center justify-center rounded-md text-blue-500 hover:text-blue-700 cursor-pointer"
35
- >
36
- <IconPenSolid class="w-5 h-5" />
37
- </div>
38
-
39
- <template #tooltip>
40
- {{$t('Rename passkey')}}
41
- </template>
42
- </Tooltip>
41
+ <button
42
+ @click="passkeysNewName = ''"
43
+ class="af-passkey-btn-rename w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
44
+ >
45
+ <IconPenSolid class="w-3.5 h-3.5" />
46
+ {{$t('Rename')}}
47
+ </button>
43
48
  </template>
44
- <div>
45
- <p>{{$t('Enter new passkey name:')}}</p>
46
- <input
47
- v-model="passkeysNewName"
48
- type="text"
49
- class="w-full mt-2 p-2 border rounded-md dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-gray-500 dark:bg-gray-800"
50
- placeholder="Enter new passkey name"
49
+ <div class="flex flex-col gap-2">
50
+ <label class="text-sm font-medium">{{$t('Current name:')}}</label>
51
+ <p class="font-medium text-sm text-gray-900 dark:text-white pb-2 truncate" :title="item.name">{{ item.name }}</p>
52
+ <label class="text-sm font-medium">{{$t('Enter new passkey name:')}}</label>
53
+ <Input
54
+ v-model="passkeysNewName"
55
+ type="text"
56
+ class="w-full"
57
+ :placeholder="$t('New name')"
51
58
  />
52
59
  </div>
53
60
  </Dialog>
54
- <Dialog
55
- class="delete-passkey-confirmation-dialog w-96"
56
- :buttons="[
57
- { label: 'Delete', options: {class: 'dialog-delete-button'}, onclick: (dialog) => { deletePasskey(item.id); dialog.hide(); } },
58
- { label: 'Cancel', options: {class: 'dialog-cancel-button'}, onclick: (dialog) => dialog.hide() },
59
- ]"
60
- header="Delete Passkey"
61
- >
62
- <template #trigger>
63
- <Tooltip>
64
- <div class="w-7 h-7 flex items-center justify-center rounded-md text-red-500 hover:text-red-700 cursor-pointer">
65
- <IconTrashBinSolid
66
- class="w-5 h-5"
67
- />
68
- </div>
69
- <template #tooltip>
70
- {{$t('Delete passkey')}}
71
- </template>
72
- </Tooltip>
73
- </template>
74
- <div>
75
- <p>{{$t('Are you sure you want to delete this passkey?')}}</p>
76
- </div>
77
- </Dialog>
78
- </div>
79
- </template>
80
- <template #cell:last_used_at="{item}">
81
- <span v-if="item.last_used_at">{{ formatDateTime(item.last_used_at) }}</span>
82
- <span v-else class="text-gray-400">{{$t('Never')}}</span>
83
- </template>
84
- </Table>
85
- <div class="flex space-x-4 mt-4" v-if="isInitialFinished">
86
- <ButtonGroup :solidColor="true" v-if="isFetchingPasskey === false">
87
- <template #button:Profile>
88
- <div class="flex px-4 py-2" @click="addPasskeyStartAction()">
89
- <IconPlusOutline class="w-5 h-5 me-2"/>
90
- <p>{{ addPasskeyMode === 'platform' ? 'Add Local Passkey' : 'Add External Passkey' }}</p>
91
- </div>
92
- </template>
93
- <template #button:Dropdown v-if="authenticatorAttachment === 'both'">
94
- <div id="dropdown-button" class="flex px-2 py-2" @click="isCardsVisible = !isCardsVisible">
95
- <IconCaretDownSolid class="w-5 h-5"/>
96
- </div>
97
- </template>
98
- </ButtonGroup>
99
- <p v-else class="flex items-center justify-center gap-2 text-base">{{$t('Processing')}} <Spinner class="w-4 h-4 inline-block" /></p>
100
- </div>
101
- <div v-if="isCardsVisible" id="cards-container" class="w-80 mt-2 border-gray-400 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md flex flex-col space-y-2">
102
- <div v-if="isPasskeySupported" class="flex justify-between gap-4" :class="!isPasskeySupported ? 'opacity-50 pointer-events-none' : ''">
103
- <div class="shrink-0 mt-1 w-4 h-4 z-10"><IconCheckOutline v-if="addPasskeyMode === 'platform'"/></div>
104
- <Card
105
- @click="addPasskeyMode = 'platform'; isCardsVisible = false;"
106
- class="h-20"
107
- title="Use this device"
108
- size="sm"
109
- description="Create a passkey using the built-in authenticator on this device."
110
- hideBorder="true"
61
+
62
+ <button
63
+ @click="confirmAndDelete(item.id)"
64
+ class="af-passkey-btn-remove flex items-center justify-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer"
111
65
  >
112
- </Card>
66
+ <IconTrashBinSolid class="w-3.5 h-3.5" />
67
+ {{$t('Remove')}}
68
+ </button>
113
69
  </div>
114
- <Tooltip v-else >
115
- <div class="flex justify-between gap-4" :class="!isPasskeySupported ? 'opacity-50 pointer-events-none' : ''">
116
- <div class="shrink-0 mt-1 w-4 h-4 z-10"><IconCheckOutline v-if="addPasskeyMode === 'platform'"/></div>
117
- <Card
118
- @click="addPasskeyMode = 'platform'; isCardsVisible = false;"
119
- class="h-20"
120
- title="Use this device"
121
- size="sm"
122
- description="Create a passkey using the built-in authenticator on this device."
123
- hideBorder="true"
124
- >
125
- </Card>
126
- </div>
70
+ </div>
127
71
 
72
+ <template v-if="isInitialFinished">
73
+ <Tooltip v-if="!isPasskeySupported && (authenticatorAttachment === 'platform' || authenticatorAttachment === 'both')">
74
+ <div class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 w-full lg:w-72 min-h-36 opacity-50 pointer-events-none">
75
+ <div class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
76
+ <IconPlusOutline class="w-5 h-5 text-gray-500 dark:text-gray-400" />
77
+ </div>
78
+ <p class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add local passkey')}}</p>
79
+ <p class="af-passkey-add-card-subtitle text-xs text-gray-400 dark:text-gray-500 text-center">{{$t('Register this device')}}</p>
80
+ </div>
128
81
  <template #tooltip>
129
82
  <p class="max-w-64">{{$t('This browser or device is reporting partial passkey support.')}}</p>
130
83
  <p class="max-w-64">{{$t('To fix it try to install extention or change browser.')}}</p>
131
84
  </template>
132
85
  </Tooltip>
133
- <div class="border-t border-gray-300"></div>
134
- <div class="flex justify-between gap-4">
135
- <div class="shrink-0 mt-1 w-4 h-4 z-10"><IconCheckOutline v-if="addPasskeyMode === 'cross-platform'"/></div>
136
- <Card
137
- @click="addPasskeyMode = 'cross-platform'; isCardsVisible = false;"
138
- size="sm"
139
- class="h-20"
140
- title="Use an external device"
141
- description="Create a passkey using a phone, tablet or an external security key."
142
- hideBorder="true"
143
- >
144
- </Card>
86
+
87
+ <div
88
+ v-else-if="authenticatorAttachment === 'platform' || authenticatorAttachment === 'both'"
89
+ @click="addLocalPasskey"
90
+ class="af-passkey-add-card af-passkey-add-local flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors w-full lg:w-72 min-h-36"
91
+ :class="isFetchingPasskey ? 'opacity-50 pointer-events-none' : 'cursor-pointer'"
92
+ >
93
+ <div class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
94
+ <Spinner v-if="isFetchingPasskey && addPasskeyMode === 'platform'" class="w-5 h-5" />
95
+ <IconPlusOutline v-else class="w-5 h-5 text-gray-500 dark:text-gray-400" />
96
+ </div>
97
+ <p class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add local passkey')}}</p>
98
+ <p class="af-passkey-add-card-subtitle text-xs text-gray-400 dark:text-gray-500 text-center">{{$t('Register this device')}}</p>
145
99
  </div>
146
- </div>
147
- </div>
148
- <Dialog
149
- ref="confirmDialog"
150
- class="add-passkey-confirmation-dialog w-96"
151
- :click-to-close-outside="false"
152
- :buttons="[]"
153
- :closable="false"
154
- >
155
- <div class="flex flex-col">
156
- <button
157
- type="button"
158
- class="text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
159
- @click="{ isFetchingPasskey = false; fetchedOptions = null; closeDialog(); }"
100
+
101
+ <div
102
+ v-if="authenticatorAttachment === 'cross-platform' || authenticatorAttachment === 'both'"
103
+ @click="addCrossDevicePasskey"
104
+ class="af-passkey-add-card af-passkey-add-cross flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-purple-400 dark:hover:border-purple-500 hover:bg-purple-50/50 dark:hover:bg-purple-900/10 transition-colors w-full lg:w-72 min-h-36"
105
+ :class="isFetchingPasskey ? 'opacity-50 pointer-events-none' : 'cursor-pointer'"
160
106
  >
161
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
162
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
163
- </svg>
164
- <span class="sr-only">{{$t('Close dialog')}}</span>
165
- </button>
166
- <div class="flex items-center justify-center space-y-4">
167
- {{$t('Now you can add a passkey.')}}
107
+ <div class="af-passkey-add-card-icon w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
108
+ <Spinner v-if="isFetchingPasskey && addPasskeyMode === 'cross-platform'" class="w-5 h-5" />
109
+ <IconPlusOutline v-else class="w-5 h-5 text-gray-500 dark:text-gray-400" />
110
+ </div>
111
+ <p class="af-passkey-add-card-title font-medium text-gray-700 dark:text-gray-300 text-sm">{{$t('Add cross-device')}}</p>
112
+ <p class="af-passkey-add-card-subtitle text-xs text-gray-400 dark:text-gray-500 text-center">{{$t('Use phone or security key')}}</p>
168
113
  </div>
169
- <Button class="mt-4" @click="addPasskeyFinishAction(fetchedOptions); closeDialog();">{{$t('Add Passkey')}}</Button>
170
- </div>
171
- </Dialog>
114
+ </template>
115
+ </div>
116
+
117
+ <div class="af-passkeys-info mt-6 flex items-center gap-2 px-4 py-3 bg-blue-50 w-full md:w-fit dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-xl text-sm text-blue-700 dark:text-blue-300">
118
+ <IconInfoCircleOutline class="af-passkeys-info-icon w-4 h-4 shrink-0" />
119
+ <span class="af-passkeys-info-text">{{$t('Passkeys are a more secure alternative to passwords. They use your device\'s biometrics or PIN and are resistant to phishing attacks.')}}</span>
120
+ </div>
121
+
172
122
  </div>
173
123
  </template>
174
124
 
175
- <script setup lang="ts">
176
- import { Table } from '@/afcl'
125
+ <script setup lang="ts">
177
126
  import { callAdminForthApi } from '@/utils';
178
- import adminforth from '@/adminforth';
179
- import { onMounted, ref, Ref, onBeforeUnmount } from 'vue';
180
- import { Card, Dialog, ButtonGroup, Tooltip, Spinner, Button } from '@/afcl'
181
- import { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconCaretDownSolid, IconCheckOutline } from '@iconify-prerendered/vue-flowbite';
127
+ import adminforth, { useAdminforth } from '@/adminforth';
128
+ import { onMounted, ref, Ref } from 'vue';
129
+ import { useI18n } from 'vue-i18n';
130
+ import { Dialog, Tooltip, Spinner, Input } from '@/afcl';
131
+ import { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconInfoCircleOutline } from '@iconify-prerendered/vue-flowbite';
182
132
  import dayjs from 'dayjs';
183
133
  import utc from 'dayjs/plugin/utc';
184
134
  import timezone from 'dayjs/plugin/timezone';
185
135
  import { useCoreStore } from '@/stores/core';
186
136
 
137
+ const { t } = useI18n();
187
138
  dayjs.extend(utc);
188
139
  dayjs.extend(timezone);
189
140
  const coreStore = useCoreStore();
190
- const passkeys = ref([]);
141
+
142
+ interface Passkey {
143
+ id: string;
144
+ name: string;
145
+ light_icon: string;
146
+ dark_icon: string;
147
+ last_used_at: string | null;
148
+ }
149
+ const passkeys = ref<Passkey[]>([]);
191
150
  const isPasskeySupported = ref(false);
192
151
  const passkeysNewName = ref('');
193
- const isCardsVisible = ref(false);
194
152
  const addPasskeyMode: Ref<'platform' | 'cross-platform'> = ref('platform');
195
153
  const authenticatorAttachment = ref<'platform' | 'cross-platform' | 'both'>('platform');
196
154
  const isInitialFinished = ref(false);
197
155
  const isFetchingPasskey = ref(false);
198
156
 
199
- const confirmDialog = ref(null);
200
-
201
- const openDialog = () => {
202
- confirmDialog.value.open();
203
- }
204
-
205
- const closeDialog = () => {
206
- confirmDialog.value.close();
207
- }
208
-
209
- const fetchedOptions = ref(null);
210
-
211
157
  onMounted(async () => {
212
158
  await getPasskeys();
213
- await checkForCompatibility();
214
- if (authenticatorAttachment.value === "cross-platform") {
159
+ await checkPlatformSupport();
160
+ if (authenticatorAttachment.value === 'cross-platform') {
215
161
  addPasskeyMode.value = 'cross-platform';
216
- }
162
+ }
217
163
  isInitialFinished.value = true;
218
164
  });
219
165
 
220
- onMounted(() => {
221
- document.addEventListener('click', handleClickOutside);
222
- });
223
-
224
- onBeforeUnmount(() => {
225
- document.removeEventListener('click', handleClickOutside);
226
- });
227
-
228
- function handleClickOutside(event: MouseEvent) {
229
- const cards = document.getElementById('cards-container');
230
- const dropdownButton = document.getElementById('dropdown-button');
166
+ function addLocalPasskey() {
167
+ addPasskeyMode.value = 'platform';
168
+ startAddPasskey();
169
+ }
231
170
 
232
- if (!cards?.contains(event.target as Node) && !dropdownButton?.contains(event.target as Node)) {
233
- isCardsVisible.value = false;
234
- }
171
+ function addCrossDevicePasskey() {
172
+ addPasskeyMode.value = 'cross-platform';
173
+ startAddPasskey();
235
174
  }
236
175
 
237
- async function addPasskeyStartAction() {
176
+ async function startAddPasskey() {
238
177
  isFetchingPasskey.value = true;
239
- let confirmationResult;
240
178
  try {
241
- confirmationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult("To add passkey first verify yourself");
242
- } catch (e) {
243
- isFetchingPasskey.value = false;
244
- return;
245
- }
246
- if (!confirmationResult) {
247
- isFetchingPasskey.value = false;
248
- return;
249
- }
250
- const { options } = await fetchInformationFromTheBackend(confirmationResult);
251
- if (!options ) {
179
+ const confirmationResult = await window.adminforthTwoFaModal
180
+ .get2FaConfirmationResult('To add passkey first verify yourself');
181
+
182
+ if (!confirmationResult) return;
183
+
184
+ const { options, error } = await requestPasskeyChallenge(confirmationResult);
185
+ if (!options) {
186
+ adminforth.alert({ message: error ?? 'Verification failed.', variant: 'warning' });
187
+ return;
188
+ }
189
+
190
+ await finishAddPasskey(options);
191
+ } catch {
192
+ // user cancelled 2FA dialog
193
+ } finally {
252
194
  isFetchingPasskey.value = false;
253
- adminforth.alert({message: 'Verification failed.', variant: 'warning'});
254
- return;
255
195
  }
256
- fetchedOptions.value = options;
257
- openDialog();
258
196
  }
259
197
 
260
- async function addPasskeyFinishAction(options) {
261
- const creationResult = await callWebAuthn(options);
262
- if (!creationResult) {
263
- isFetchingPasskey.value = false;
264
- fetchedOptions.value = null;
265
- return;
266
- }
267
- fetchedOptions.value = null;
268
- finishRegisteringPasskey(creationResult);
269
- isFetchingPasskey.value = false;
198
+ async function finishAddPasskey(options: PublicKeyCredentialCreationOptions) {
199
+ const credential = await createWebAuthnCredential(options);
200
+ if (!credential) return;
201
+ await registerPasskey(credential);
270
202
  }
271
203
 
272
204
  async function getPasskeys() {
273
205
  try {
274
206
  const response = await callAdminForthApi({
275
- path: `/plugin/passkeys/getPasskeys`,
207
+ path: '/plugin/passkeys/getPasskeys',
276
208
  method: 'GET',
277
209
  });
278
210
  passkeys.value = response.data;
279
211
  authenticatorAttachment.value = response.authenticatorAttachment;
280
212
  } catch (error) {
281
213
  console.error('Error fetching passkeys:', error);
282
- if ( coreStore.adminUser.username ) {
283
- adminforth.alert({message: 'Error fetching passkeys.', variant: 'warning'});
214
+ if (coreStore.adminUser?.username) {
215
+ adminforth.alert({ message: 'Error fetching passkeys.', variant: 'warning' });
284
216
  }
285
217
  }
286
218
  }
287
219
 
288
- async function deletePasskey(passkeyId: string) {
289
- let response;
220
+ type ConfirmationResult = { mode: 'totp'; result: string } | { mode: 'passkey'; result: Record<string, any> };
221
+
222
+ async function requestPasskeyChallenge(confirmationResult: ConfirmationResult) {
290
223
  try {
291
- response = await callAdminForthApi({
292
- path: `/plugin/passkeys/deletePasskey`,
293
- method: 'DELETE',
294
- body: {
295
- passkeyId: passkeyId
296
- }
297
- })
224
+ const response = await callAdminForthApi({
225
+ path: '/plugin/passkeys/registerPasskeyRequest',
226
+ method: 'POST',
227
+ body: { mode: addPasskeyMode.value, confirmationResult },
228
+ });
229
+ if (!response.ok) return { error: response.error ?? 'Verification failed' };
230
+ const options = PublicKeyCredential.parseCreationOptionsFromJSON(response.data);
231
+ return { options, challengeId: response.challengeId };
298
232
  } catch (error) {
299
- console.error('Error deleting passkey:', error);
300
- adminforth.alert({message: 'Error deleting passkey.', variant: 'warning'});
301
- }
302
- if (response.ok === true) {
303
- adminforth.alert({message: 'Passkey deleted successfully!', variant: 'success'});
304
- getPasskeys();
305
- } else {
306
- console.error('Error deleting passkey:', response?.error);
307
- adminforth.alert({message: 'Error deleting passkey.', variant: 'warning'});
233
+ console.error('Error requesting passkey challenge:', error);
234
+ return { error: 'Failed to request passkey challenge' };
308
235
  }
309
236
  }
310
237
 
311
- async function renamePasskey(passkeyId: string, name: string) {
312
- let response;
238
+ async function createWebAuthnCredential(options: PublicKeyCredentialCreationOptions) {
313
239
  try {
314
- response = await callAdminForthApi({
315
- path: `/plugin/passkeys/renamePasskey`,
316
- method: 'POST',
317
- body: {
318
- passkeyId: passkeyId,
319
- newName: name
320
- }
321
- })
240
+ const credential = await navigator.credentials.create({ publicKey: options });
241
+ return JSON.stringify((credential as PublicKeyCredential).toJSON());
322
242
  } catch (error) {
323
- console.error('Error updating passkey:', error);
324
- adminforth.alert({message: 'Error updating passkey.', variant: 'warning'});
325
- }
326
- if (response.ok === true) {
327
- adminforth.alert({message: 'Passkey updated successfully!', variant: 'success'});
328
- getPasskeys();
329
- } else {
330
- console.error('Error updating passkey:', response.error);
331
- adminforth.alert({message: 'Error updating passkey.', variant: 'warning'});
243
+ console.error('Error creating WebAuthn credential:', error);
244
+ adminforth.alert({ message: 'Error creating passkey.', variant: 'warning' });
245
+ return null;
332
246
  }
333
247
  }
334
248
 
335
- function checkForCompatibility() {
336
- if (window.PublicKeyCredential &&
337
- PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable &&
338
- PublicKeyCredential.isConditionalMediationAvailable) {
339
- Promise.all([
340
- PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
341
- PublicKeyCredential.isConditionalMediationAvailable(),
342
- ]).then(results => {
343
- if (results.every(r => r === true)) {
344
- isPasskeySupported.value = true;
345
- } else {
346
- isPasskeySupported.value = false;
347
- addPasskeyMode.value = 'cross-platform';
348
- }
349
- });
350
- }
351
- }
352
-
353
- async function fetchInformationFromTheBackend(confirmationResult) {
354
- let response;
249
+ async function registerPasskey(credential: string) {
355
250
  try {
356
- response = await callAdminForthApi({
357
- path: `/plugin/passkeys/registerPasskeyRequest`,
251
+ const res = await callAdminForthApi({
252
+ path: '/plugin/passkeys/finishRegisteringPasskey',
358
253
  method: 'POST',
359
- body: {
360
- mode: addPasskeyMode.value,
361
- confirmationResult: confirmationResult
362
- },
254
+ body: { credential, origin: window.location.origin },
363
255
  });
256
+ if (res.ok) {
257
+ adminforth.alert({ message: 'Passkey registered successfully!', variant: 'success' });
258
+ getPasskeys();
259
+ } else {
260
+ adminforth.alert({ message: 'Error registering passkey.', variant: 'warning' });
261
+ }
364
262
  } catch (error) {
365
- console.error('Error fetching passkeys info:', error);
366
- return;
367
- }
368
- if (!response.ok) {
369
- return {};
263
+ console.error('Error registering passkey:', error);
370
264
  }
371
- const _options = response.data;
372
- const challengeId = response.challengeId;
373
- const options = PublicKeyCredential.parseCreationOptionsFromJSON(_options);
374
- return { options, challengeId };
375
265
  }
376
266
 
377
- async function callWebAuthn(options: any) {
378
- let credential;
267
+ async function confirmAndDelete(passkeyId: string) {
268
+ const { confirm } = useAdminforth();
269
+ const confirmed = await confirm({
270
+ title: t('Remove passkey'),
271
+ message: t('Are you sure you want to remove this passkey?'),
272
+ yes: t('Remove'),
273
+ no: t('Cancel'),
274
+ });
275
+ if (confirmed) deletePasskey(passkeyId);
276
+ }
277
+
278
+ async function deletePasskey(passkeyId: string) {
379
279
  try {
380
- credential = await navigator.credentials.create({
381
- publicKey: options
280
+ const response = await callAdminForthApi({
281
+ path: '/plugin/passkeys/deletePasskey',
282
+ method: 'DELETE',
283
+ body: { passkeyId },
382
284
  });
285
+ if (response.ok) {
286
+ adminforth.alert({ message: 'Passkey deleted successfully!', variant: 'success' });
287
+ getPasskeys();
288
+ } else {
289
+ console.error('Error deleting passkey:', response?.error);
290
+ adminforth.alert({ message: 'Error deleting passkey.', variant: 'warning' });
291
+ }
383
292
  } catch (error) {
384
- console.error('Error creating credential:', error);
385
- adminforth.alert({message: 'Error creating passkey.', variant: 'warning'});
386
- return;
293
+ console.error('Error deleting passkey:', error);
294
+ adminforth.alert({ message: 'Error deleting passkey.', variant: 'warning' });
387
295
  }
388
- const _result = (credential as PublicKeyCredential).toJSON();
389
- const result = JSON.stringify(_result);
390
- return result;
391
296
  }
392
297
 
393
- async function finishRegisteringPasskey(credential: any) {
394
- let res
298
+ async function renamePasskey(passkeyId: string, name: string) {
395
299
  try {
396
- res = await callAdminForthApi({
397
- path: `/plugin/passkeys/finishRegisteringPasskey`,
300
+ const response = await callAdminForthApi({
301
+ path: '/plugin/passkeys/renamePasskey',
398
302
  method: 'POST',
399
- body: {
400
- credential: credential,
401
- origin: window.location.origin,
402
- },
303
+ body: { passkeyId, newName: name },
403
304
  });
305
+ if (response.ok) {
306
+ adminforth.alert({ message: 'Passkey updated successfully!', variant: 'success' });
307
+ getPasskeys();
308
+ } else {
309
+ console.error('Error updating passkey:', response.error);
310
+ adminforth.alert({ message: 'Error updating passkey.', variant: 'warning' });
311
+ }
404
312
  } catch (error) {
405
- console.error('Error finishing registering passkey:', error);
313
+ console.error('Error updating passkey:', error);
314
+ adminforth.alert({ message: 'Error updating passkey.', variant: 'warning' });
315
+ }
316
+ }
317
+
318
+ async function checkPlatformSupport() {
319
+ if (!window.PublicKeyCredential
320
+ || !PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable
321
+ || !PublicKeyCredential.isConditionalMediationAvailable) {
322
+ isPasskeySupported.value = false;
323
+ addPasskeyMode.value = 'cross-platform';
406
324
  return;
407
325
  }
408
- if (res.ok === true) {
409
- adminforth.alert({message: 'Passkey registered successfully!', variant: 'success'});
410
- getPasskeys();
411
- } else {
412
- adminforth.alert({message: 'Error registering passkey.', variant: 'warning'});
326
+ const [platformOk, conditionalOk] = await Promise.all([
327
+ PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
328
+ PublicKeyCredential.isConditionalMediationAvailable(),
329
+ ]);
330
+ isPasskeySupported.value = platformOk && conditionalOk;
331
+ if (!isPasskeySupported.value) {
332
+ addPasskeyMode.value = 'cross-platform';
413
333
  }
414
334
  }
415
335
 
416
336
  function formatDateTime(date: string) {
417
337
  if (!date) return '';
418
- return dayjs.utc(date).local().format(`${coreStore.config?.datesFormat} ${coreStore.config?.timeFormat}` || 'YYYY-MM-DD HH:mm:ss');
338
+ const fmt = `${coreStore.config?.datesFormat} ${coreStore.config?.timeFormat}` || 'YYYY-MM-DD HH:mm:ss';
339
+ return dayjs.utc(date).local().format(fmt);
419
340
  }
420
341
  </script>
421
342
 
@@ -426,27 +347,10 @@
426
347
  color: white;
427
348
  border: 1px solid rgb(153, 27, 27);
428
349
  }
350
+ .dialog-delete-button.dialog-delete-button:hover { background-color: rgb(153, 27, 27); }
351
+ .dialog-delete-button.dialog-delete-button:active { background-color: rgb(127, 20, 20); }
429
352
 
430
- .dialog-delete-button.dialog-delete-button:hover {
431
- background-color: rgb(153, 27, 27);
432
- }
433
-
434
- .dialog-delete-button.dialog-delete-button:active {
435
- background-color: rgb(127, 20, 20);
436
- }
437
-
438
- /* Dark theme styles for delete button */
439
- .dark .dialog-delete-button.dialog-delete-button {
440
- background-color: rgb(220, 38, 38);
441
- color: white;
442
- }
443
-
444
- .dark .dialog-delete-button.dialog-delete-button:hover {
445
- background-color: rgb(185, 28, 28);
446
- }
447
-
448
- .dark .dialog-delete-button.dialog-delete-button:active {
449
- background-color: rgb(153, 27, 27);
450
- }
451
-
353
+ .dark .dialog-delete-button.dialog-delete-button { background-color: rgb(220, 38, 38); color: white; }
354
+ .dark .dialog-delete-button.dialog-delete-button:hover { background-color: rgb(185, 28, 28); }
355
+ .dark .dialog-delete-button.dialog-delete-button:active { background-color: rgb(153, 27, 27); }
452
356
  </style>