Adds in authentication system and overhauls the navigation bar to be built dynamically.

This commit is contained in:
Cameron Redmore 2025-04-24 21:35:52 +01:00
parent 7e98b5345d
commit 28c054de22
21 changed files with 1531 additions and 56 deletions

36
src/pages/LandingPage.vue Normal file
View file

@ -0,0 +1,36 @@
<template>
<q-page class="landing-page column items-center q-pa-md">
<div class="hero text-center q-pa-xl full-width">
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">Welcome to StylePoint</h1>
<p class="text-h6 text-grey-8 q-mb-lg">The all-in-one tool designed for StyleTech Developers.</p>
</div>
<div class="features q-mt-xl q-pa-md text-center" style="max-width: 800px; width: 100%;">
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">Features</h2>
<q-list bordered separator class="rounded-borders">
<q-item v-for="(feature, index) in features" :key="index" class="q-pa-md">
<q-item-section>
<q-item-label class="text-body1">{{ feature }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const currentYear = ref(new Date().getFullYear());
const features = ref([
'Auatomated Daily Reports',
'Deep Mantis Integration',
'Easy Authentication',
'And more..?'
]);
</script>

103
src/pages/LoginPage.vue Normal file
View file

@ -0,0 +1,103 @@
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">Login</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
@keyup.enter="handleLogin"
:hint="errorMessage ? errorMessage : ''"
:rules="[val => !!val || 'Username is required']"
/>
<q-btn
label="Login with Passkey"
color="primary"
class="full-width"
@click="handleLogin"
:loading="loading"
/>
<div v-if="errorMessage" class="text-negative q-mt-md">{{ errorMessage }}</div>
</q-card-section>
<q-card-actions align="center">
<q-btn flat label="Don't have an account? Register" to="/register" />
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { startAuthentication } from '@simplewebauthn/browser';
import axios from 'axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const username = ref('');
const loading = ref(false);
const errorMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
async function handleLogin() {
loading.value = true;
errorMessage.value = '';
try {
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-authentication-options', {
username: username.value || undefined, // Send username if provided
});
const options = optionsRes.data;
// 2. Start authentication ceremony in browser
const authResp = await startAuthentication(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/auth/verify-authentication', {
authenticationResponse: authResp,
});
if (verificationRes.data.verified) {
// Update the auth store on successful login
authStore.isAuthenticated = true;
authStore.user = verificationRes.data.user;
authStore.error = null; // Clear any previous errors
console.log('Login successful:', verificationRes.data.user);
router.push('/'); // Redirect to home page
} else {
errorMessage.value = 'Authentication failed.';
// Optionally update store state on failure
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = 'Authentication failed.';
}
} catch (error) {
console.error('Login error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.';
// Handle specific simplewebauthn errors if needed
if (error.name === 'NotAllowedError') {
errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
} else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found')) {
errorMessage.value = 'User not found. Please check your username or register.';
} else if (error.response?.status === 404 && error.response?.data?.error?.includes('Authenticator not found')) {
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.';
} else {
errorMessage.value = `Login failed: ${message}`;
}
// Optionally update store state on error
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = `Login failed: ${message}`;
} finally {
loading.value = false;
}
}
</script>

View file

@ -0,0 +1,275 @@
<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>
<!-- Passkey List Section -->
<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-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
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 startAuthentication
import axios from 'axios';
import { useAuthStore } from 'stores/auth';
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); // Store the ID of the passkey being identified
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
// 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
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try {
const response = await axios.get('/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 = []; // Clear passkeys on error
} finally {
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async () => {
let initialAuthError = '';
if (!authStore.isAuthenticated) {
await authStore.checkAuthStatus();
if (authStore.error) {
initialAuthError = `Authentication error: ${authStore.error}`;
}
}
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
}
});
async function handleRegister() {
if (!isLoggedIn.value || !username.value) {
registerErrorMessage.value = 'User not authenticated.';
return;
}
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try {
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: username.value, // Use username from store
});
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('/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified) {
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
} 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.';
// Handle specific simplewebauthn errors
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;
}
}
// 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;
// }
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;
try {
await axios.delete(`/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
}
}
// Handle identifying a passkey
async function handleIdentify() {
if (!isLoggedIn.value) {
identifyErrorMessage.value = 'You must be logged in.';
return;
}
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
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('/auth/generate-authentication-options', {}); // Send empty body
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
} 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; // Clear loading state
}
}
</script>

135
src/pages/RegisterPage.vue Normal file
View file

@ -0,0 +1,135 @@
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<!-- Update title based on login status from store -->
<div class="text-h6">{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
:rules="[val => !!val || 'Username is required']"
@keyup.enter="handleRegister"
:disable="isLoggedIn"
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''"
:readonly="isLoggedIn"
/>
<q-btn
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'"
color="primary"
class="full-width"
@click="handleRegister"
:loading="loading"
:disable="loading || (!username && !isLoggedIn)"
/>
<div v-if="successMessage" class="text-positive q-mt-md">{{ successMessage }}</div>
<div v-if="errorMessage" class="text-negative q-mt-md">{{ errorMessage }}</div>
</q-card-section>
<q-card-actions align="center">
<!-- Hide login link if already logged in based on store state -->
<q-btn v-if="!isLoggedIn" flat label="Already have an account? Login" to="/login" />
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'; // Import computed
import { useRouter } from 'vue-router';
import { startRegistration } from '@simplewebauthn/browser';
import axios from 'axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const loading = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = ref(''); // Local ref for username input
// Check auth status on component mount using the store action
onMounted(async () => {
if (!authStore.isAuthenticated) {
await authStore.checkAuthStatus();
if (authStore.error) {
errorMessage.value = authStore.error;
}
}
if (!isLoggedIn.value) {
username.value = ''; // Clear username if not logged in
} else {
username.value = authStore.user?.username || ''; // Use username from store if logged in
}
});
async function handleRegister() {
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
if (!currentUsername) {
errorMessage.value = 'Username is missing.';
return;
}
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
try {
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: currentUsername, // Use username from store
});
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('/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified) {
// Adjust success message based on login state
successMessage.value = isLoggedIn.value
? 'New passkey registered successfully!'
: 'Registration successful! Redirecting to login...';
if (!isLoggedIn.value) {
// Redirect to login page only if they weren't logged in
setTimeout(() => {
router.push('/login');
}, 2000);
} else {
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
} else {
errorMessage.value = 'Registration failed.';
}
} catch (error) {
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') {
errorMessage.value = 'Authenticator already registered. Try logging in instead.';
} else if (error.name === 'NotAllowedError') {
errorMessage.value = 'Registration ceremony was cancelled or timed out.';
} else if (error.response?.status === 409) {
errorMessage.value = 'This passkey seems to be registered already.';
} else {
errorMessage.value = `Registration failed: ${message}`;
}
} finally {
loading.value = false;
}
}
</script>