@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.
- package/build.log +2 -2
- package/custom/TwoFAModal.vue +74 -49
- package/custom/TwoFactorsPasskeysSettings.vue +237 -333
- package/dist/custom/TwoFAModal.vue +74 -49
- package/dist/custom/TwoFactorsPasskeysSettings.vue +237 -333
- package/dist/index.js +1 -1
- package/index.ts +2 -2
- package/package.json +1 -1
|
@@ -1,421 +1,342 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<
|
|
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: '
|
|
26
|
-
{ label: '
|
|
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
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
66
|
+
<IconTrashBinSolid class="w-3.5 h-3.5" />
|
|
67
|
+
{{$t('Remove')}}
|
|
68
|
+
</button>
|
|
113
69
|
</div>
|
|
114
|
-
|
|
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
|
-
|
|
134
|
-
<div
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
>
|
|
144
|
-
</
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
<
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
180
|
-
import {
|
|
181
|
-
import {
|
|
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
|
-
|
|
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
|
|
214
|
-
if (authenticatorAttachment.value ===
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
171
|
+
function addCrossDevicePasskey() {
|
|
172
|
+
addPasskeyMode.value = 'cross-platform';
|
|
173
|
+
startAddPasskey();
|
|
235
174
|
}
|
|
236
175
|
|
|
237
|
-
async function
|
|
176
|
+
async function startAddPasskey() {
|
|
238
177
|
isFetchingPasskey.value = true;
|
|
239
|
-
let confirmationResult;
|
|
240
178
|
try {
|
|
241
|
-
confirmationResult = await window.adminforthTwoFaModal
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
261
|
-
const
|
|
262
|
-
if (!
|
|
263
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
289
|
-
|
|
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:
|
|
293
|
-
method: '
|
|
294
|
-
body: {
|
|
295
|
-
|
|
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
|
|
300
|
-
|
|
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
|
|
312
|
-
let response;
|
|
238
|
+
async function createWebAuthnCredential(options: PublicKeyCredentialCreationOptions) {
|
|
313
239
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
324
|
-
adminforth.alert({message: 'Error
|
|
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
|
|
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
|
-
|
|
357
|
-
path:
|
|
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
|
|
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
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
385
|
-
adminforth.alert({message: 'Error
|
|
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
|
|
394
|
-
let res
|
|
298
|
+
async function renamePasskey(passkeyId: string, name: string) {
|
|
395
299
|
try {
|
|
396
|
-
|
|
397
|
-
path:
|
|
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
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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:
|
|
431
|
-
|
|
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>
|