Overhaul settings and implement user preferences. Also implements dark theme toggle as part of the user settings.

This commit is contained in:
Cameron Redmore 2025-04-25 17:32:33 +01:00
parent b84f0907a8
commit 727746030c
17 changed files with 760 additions and 378 deletions

View file

@ -26,7 +26,6 @@
</div>
</div>
<!-- Passkey List Section -->
<q-card-section>
<h5>Your Registered Passkeys</h5>
<q-list
@ -61,14 +60,12 @@
>
Verified just now!
</q-item-label>
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
</q-item-section>
<q-item-section
side
class="row no-wrap items-center"
>
<!-- Delete Button -->
<q-btn
flat
dense
@ -125,9 +122,10 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
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('');
@ -137,27 +135,27 @@ const fetchErrorMessage = ref('');
const deleteLoading = ref(null);
const deleteErrorMessage = ref('');
const deleteSuccessMessage = ref('');
const identifyLoading = ref(null); // Store the ID of the passkey being identified
const identifyLoading = ref(null);
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const identifiedPasskeyId = ref(null);
const $q = useQuasar();
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
const passkeys = ref([]);
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys()
{
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
const response = await axios.get('/api/auth/passkeys');
@ -167,7 +165,7 @@ async function fetchPasskeys()
{
console.error('Error fetching passkeys:', error);
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
passkeys.value = []; // Clear passkeys on error
passkeys.value = [];
}
finally
{
@ -175,7 +173,6 @@ async function fetchPasskeys()
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async() =>
{
let initialAuthError = '';
@ -189,12 +186,11 @@ onMounted(async() =>
}
if (!isLoggedIn.value)
{
// Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
}
else
{
fetchPasskeys(); // Fetch passkeys if logged in
fetchPasskeys();
}
});
@ -208,23 +204,20 @@ async function handleRegister()
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
username: username.value, // Use username from store
username: username.value,
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-registration', {
registrationResponse: regResp,
});
@ -232,7 +225,7 @@ async function handleRegister()
if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
fetchPasskeys();
}
else
{
@ -243,7 +236,6 @@ async function handleRegister()
{
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError')
{
registerErrorMessage.value = 'Authenticator may already be registered.';
@ -268,42 +260,63 @@ async function handleRegister()
}
// Handle deleting a passkey
async function handleDelete(credentialID)
{
if (!credentialID) return;
// Optional: Add a confirmation dialog here
// if (!confirm('Are you sure you want to delete this passkey?')) {
// 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;
}
deleteLoading.value = credentialID; // Set loading state for the specific button
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = ''; // Clear other messages
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
$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(); // Refresh the list
}
catch (error)
{
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
}
finally
{
deleteLoading.value = null; // Clear loading state
}
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;
}
});
}
// Handle identifying a passkey
async function handleIdentify()
{
if (!isLoggedIn.value)
@ -314,8 +327,7 @@ async function handleIdentify()
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
identifiedPasskeyId.value = null;
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
@ -323,30 +335,21 @@ async function handleIdentify()
try
{
// 1. Get authentication options from the server
// We don't need to send username as the server should use the session
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {});
const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options);
// 3. If successful, the response contains the ID of the key used
identifiedPasskeyId.value = authResp.id;
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
// Optional: Add a small delay before clearing the highlight
setTimeout(() =>
{
// Only clear if it's still the same identified key
if (identifiedPasskeyId.value === authResp.id)
{
identifiedPasskeyId.value = null;
}
}, 5000); // Clear highlight after 5 seconds
}, 5000);
}
catch (error)
@ -364,7 +367,7 @@ async function handleIdentify()
}
finally
{
identifyLoading.value = null; // Clear loading state
identifyLoading.value = null;
}
}