stock-management-demo/src/pages/PasskeyManagementPage.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>