374 lines
No EOL
9.4 KiB
Vue
374 lines
No EOL
9.4 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<div class="q-mb-md row justify-between items-center">
|
|
<div class="text-h4">
|
|
Passkey Management
|
|
</div>
|
|
<div>
|
|
<q-btn
|
|
label="Identify Passkey"
|
|
color="secondary"
|
|
class="q-mx-md q-mt-md"
|
|
@click="handleIdentify"
|
|
:loading="identifyLoading"
|
|
:disable="identifyLoading || !isLoggedIn"
|
|
outline
|
|
/>
|
|
<q-btn
|
|
label="Register New Passkey"
|
|
color="primary"
|
|
class="q-mx-md q-mt-md"
|
|
@click="handleRegister"
|
|
:loading="registerLoading"
|
|
:disable="registerLoading || !isLoggedIn"
|
|
outline
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<q-card-section>
|
|
<h5>Your Registered Passkeys</h5>
|
|
<q-list
|
|
bordered
|
|
separator
|
|
v-if="passkeys.length > 0 && !fetchLoading"
|
|
>
|
|
<q-item v-if="registerSuccessMessage || registerErrorMessage">
|
|
<div
|
|
v-if="registerSuccessMessage"
|
|
class="text-positive q-mt-md"
|
|
>
|
|
{{ registerSuccessMessage }}
|
|
</div>
|
|
<div
|
|
v-if="registerErrorMessage"
|
|
class="text-negative q-mt-md"
|
|
>
|
|
{{ registerErrorMessage }}
|
|
</div>
|
|
</q-item>
|
|
<q-item
|
|
v-for="passkey in passkeys"
|
|
:key="passkey.credentialID"
|
|
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
|
|
>
|
|
<q-item-section>
|
|
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
|
|
<q-item-label
|
|
caption
|
|
v-if="identifiedPasskeyId === passkey.credentialID"
|
|
>
|
|
Verified just now!
|
|
</q-item-label>
|
|
</q-item-section>
|
|
|
|
<q-item-section
|
|
side
|
|
class="row no-wrap items-center"
|
|
>
|
|
<q-btn
|
|
flat
|
|
dense
|
|
round
|
|
color="negative"
|
|
icon="delete"
|
|
@click="handleDelete(passkey.credentialID)"
|
|
:loading="deleteLoading === passkey.credentialID"
|
|
:disable="!!deleteLoading || !!identifyLoading"
|
|
/>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
<div
|
|
v-else-if="fetchLoading"
|
|
class="q-mt-md"
|
|
>
|
|
Loading passkeys...
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="q-mt-md"
|
|
>
|
|
You have no passkeys registered yet.
|
|
</div>
|
|
|
|
<div
|
|
v-if="fetchErrorMessage"
|
|
class="text-negative q-mt-md"
|
|
>
|
|
{{ fetchErrorMessage }}
|
|
</div>
|
|
<div
|
|
v-if="deleteSuccessMessage"
|
|
class="text-positive q-mt-md"
|
|
>
|
|
{{ deleteSuccessMessage }}
|
|
</div>
|
|
<div
|
|
v-if="deleteErrorMessage"
|
|
class="text-negative q-mt-md"
|
|
>
|
|
{{ deleteErrorMessage }}
|
|
</div>
|
|
<div
|
|
v-if="identifyErrorMessage"
|
|
class="text-negative q-mt-md"
|
|
>
|
|
{{ identifyErrorMessage }}
|
|
</div>
|
|
</q-card-section>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
|
import axios from 'boot/axios';
|
|
import { useAuthStore } from 'stores/auth';
|
|
import { useQuasar } from 'quasar';
|
|
|
|
const registerLoading = ref(false);
|
|
const registerErrorMessage = ref('');
|
|
const registerSuccessMessage = ref('');
|
|
const fetchLoading = ref(false);
|
|
const fetchErrorMessage = ref('');
|
|
const deleteLoading = ref(null);
|
|
const deleteErrorMessage = ref('');
|
|
const deleteSuccessMessage = ref('');
|
|
const identifyLoading = ref(null);
|
|
const identifyErrorMessage = ref('');
|
|
const identifiedPasskeyId = ref(null);
|
|
|
|
const $q = useQuasar();
|
|
|
|
const authStore = useAuthStore();
|
|
const passkeys = ref([]);
|
|
|
|
const isLoggedIn = computed(() => authStore.isAuthenticated);
|
|
const username = computed(() => authStore.user?.username);
|
|
|
|
async function fetchPasskeys()
|
|
{
|
|
if (!isLoggedIn.value) return;
|
|
fetchLoading.value = true;
|
|
fetchErrorMessage.value = '';
|
|
deleteSuccessMessage.value = '';
|
|
deleteErrorMessage.value = '';
|
|
identifyErrorMessage.value = '';
|
|
identifiedPasskeyId.value = null;
|
|
try
|
|
{
|
|
const response = await axios.get('/api/auth/passkeys');
|
|
passkeys.value = response.data || [];
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Error fetching passkeys:', error);
|
|
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
|
|
passkeys.value = [];
|
|
}
|
|
finally
|
|
{
|
|
fetchLoading.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(async() =>
|
|
{
|
|
let initialAuthError = '';
|
|
if (!authStore.isAuthenticated)
|
|
{
|
|
await authStore.checkAuthStatus();
|
|
if (authStore.error)
|
|
{
|
|
initialAuthError = `Authentication error: ${authStore.error}`;
|
|
}
|
|
}
|
|
if (!isLoggedIn.value)
|
|
{
|
|
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
|
|
}
|
|
else
|
|
{
|
|
fetchPasskeys();
|
|
}
|
|
});
|
|
|
|
async function handleRegister()
|
|
{
|
|
if (!isLoggedIn.value || !username.value)
|
|
{
|
|
registerErrorMessage.value = 'User not authenticated.';
|
|
return;
|
|
}
|
|
registerLoading.value = true;
|
|
registerErrorMessage.value = '';
|
|
registerSuccessMessage.value = '';
|
|
deleteSuccessMessage.value = '';
|
|
deleteErrorMessage.value = '';
|
|
identifyErrorMessage.value = '';
|
|
identifiedPasskeyId.value = null;
|
|
|
|
try
|
|
{
|
|
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
|
|
username: username.value,
|
|
});
|
|
const options = optionsRes.data;
|
|
|
|
const regResp = await startRegistration(options);
|
|
|
|
const verificationRes = await axios.post('/api/auth/verify-registration', {
|
|
registrationResponse: regResp,
|
|
});
|
|
|
|
if (verificationRes.data.verified)
|
|
{
|
|
registerSuccessMessage.value = 'New passkey registered successfully!';
|
|
fetchPasskeys();
|
|
}
|
|
else
|
|
{
|
|
registerErrorMessage.value = 'Passkey verification failed.';
|
|
}
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Registration error:', error);
|
|
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
|
|
if (error.name === 'InvalidStateError')
|
|
{
|
|
registerErrorMessage.value = 'Authenticator may already be registered.';
|
|
}
|
|
else if (error.name === 'NotAllowedError')
|
|
{
|
|
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
|
|
}
|
|
else if (error.response?.status === 409)
|
|
{
|
|
registerErrorMessage.value = 'This passkey seems to be registered already.';
|
|
}
|
|
else
|
|
{
|
|
registerErrorMessage.value = `Registration failed: ${message}`;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
registerLoading.value = false;
|
|
}
|
|
}
|
|
|
|
|
|
async function handleDelete(credentialID)
|
|
{
|
|
if (!credentialID) return;
|
|
|
|
if (passkeys.value.length <= 1)
|
|
{
|
|
deleteErrorMessage.value = 'You cannot delete your last passkey. Register another one first.';
|
|
deleteSuccessMessage.value = '';
|
|
registerSuccessMessage.value = '';
|
|
registerErrorMessage.value = '';
|
|
identifyErrorMessage.value = '';
|
|
identifiedPasskeyId.value = null;
|
|
return;
|
|
}
|
|
|
|
$q.dialog({
|
|
title: 'Confirm Deletion',
|
|
message: 'Are you sure you want to delete this passkey? This action cannot be undone.',
|
|
cancel: true,
|
|
persistent: true,
|
|
ok: {
|
|
label: 'Delete',
|
|
color: 'negative',
|
|
flat: true,
|
|
},
|
|
cancel: {
|
|
label: 'Cancel',
|
|
flat: true,
|
|
},
|
|
}).onOk(async() =>
|
|
{
|
|
deleteLoading.value = credentialID;
|
|
deleteErrorMessage.value = '';
|
|
deleteSuccessMessage.value = '';
|
|
registerSuccessMessage.value = '';
|
|
registerErrorMessage.value = '';
|
|
identifyErrorMessage.value = '';
|
|
identifiedPasskeyId.value = null;
|
|
|
|
try
|
|
{
|
|
await axios.delete(`/api/auth/passkeys/${credentialID}`);
|
|
deleteSuccessMessage.value = 'Passkey deleted successfully.';
|
|
fetchPasskeys();
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Error deleting passkey:', error);
|
|
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
|
|
}
|
|
finally
|
|
{
|
|
deleteLoading.value = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleIdentify()
|
|
{
|
|
if (!isLoggedIn.value)
|
|
{
|
|
identifyErrorMessage.value = 'You must be logged in.';
|
|
return;
|
|
}
|
|
|
|
identifyLoading.value = true;
|
|
identifyErrorMessage.value = '';
|
|
identifiedPasskeyId.value = null;
|
|
registerSuccessMessage.value = '';
|
|
registerErrorMessage.value = '';
|
|
deleteSuccessMessage.value = '';
|
|
deleteErrorMessage.value = '';
|
|
|
|
try
|
|
{
|
|
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {});
|
|
const options = optionsRes.data;
|
|
|
|
const authResp = await startAuthentication(options);
|
|
|
|
identifiedPasskeyId.value = authResp.id;
|
|
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
|
|
|
|
setTimeout(() =>
|
|
{
|
|
if (identifiedPasskeyId.value === authResp.id)
|
|
{
|
|
identifiedPasskeyId.value = null;
|
|
}
|
|
}, 5000);
|
|
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error('Identification error:', error);
|
|
identifiedPasskeyId.value = null;
|
|
if (error.name === 'NotAllowedError')
|
|
{
|
|
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
|
|
}
|
|
else
|
|
{
|
|
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
identifyLoading.value = null;
|
|
}
|
|
}
|
|
|
|
</script> |