Overhaul settings and implement user preferences. Also implements dark theme toggle as part of the user settings.
This commit is contained in:
		
							parent
							
								
									b84f0907a8
								
							
						
					
					
						commit
						727746030c
					
				
					 17 changed files with 760 additions and 378 deletions
				
			
		|  | @ -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; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue