Adds in authentication system and overhauls the navigation bar to be built dynamically.
This commit is contained in:
parent
7e98b5345d
commit
28c054de22
21 changed files with 1531 additions and 56 deletions
|
@ -16,71 +16,42 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple :to="{ name: 'formList' }" exact>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Forms</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="list_alt" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Forms</q-item-label>
|
||||
<q-item-label caption>View existing forms</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Dynamic Navigation Items -->
|
||||
<q-item
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: 'mantisSummaries' }"
|
||||
:to="{ name: item.name }"
|
||||
exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Mantis Summaries</span>
|
||||
<span>{{ item.meta.title }}</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="summarize" />
|
||||
<q-icon :name="item.meta.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Mantis Summaries</q-item-label>
|
||||
<q-item-label caption>View daily summaries</q-item-label>
|
||||
<q-item-label>{{ item.meta.title }}</q-item-label>
|
||||
<q-item-label caption>{{ item.meta.caption }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Logout Button (Conditional) -->
|
||||
<q-item
|
||||
v-if="authStore.isAuthenticated"
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{ name: 'emailSummaries' }"
|
||||
exact
|
||||
@click="logout"
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Email Summaries</span>
|
||||
<span>Logout</span>
|
||||
</q-tooltip>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="email" />
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Email Summaries</q-item-label>
|
||||
<q-item-label caption>View email summaries</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item
|
||||
clickable
|
||||
to="/settings" exact
|
||||
>
|
||||
<q-tooltip anchor="center right" self="center left" >
|
||||
<span>Settings</span>
|
||||
</q-tooltip>
|
||||
<q-item-section
|
||||
avatar
|
||||
>
|
||||
<q-icon name="settings" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>Settings</q-item-label>
|
||||
<q-item-label caption>Manage application settings</q-item-label>
|
||||
<q-item-label>Logout</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
|
@ -94,11 +65,55 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ref, computed } from 'vue' // Import computed
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||
import routes from '../router/routes'; // Import routes
|
||||
|
||||
const $q = useQuasar()
|
||||
const leftDrawerOpen = ref(false)
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore(); // Use the auth store
|
||||
|
||||
// Get the child routes of the main layout
|
||||
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
|
||||
|
||||
// Compute navigation items based on auth state and route meta
|
||||
const navItems = computed(() => {
|
||||
const isAuthenticated = authStore.isAuthenticated;
|
||||
return mainLayoutRoutes.filter(route => {
|
||||
const navGroup = route.meta?.navGroup;
|
||||
if (!navGroup) return false; // Only include routes with navGroup defined
|
||||
|
||||
if (navGroup === 'always') return true;
|
||||
if (navGroup === 'auth' && isAuthenticated) return true;
|
||||
if (navGroup === 'noAuth' && !isAuthenticated) return true;
|
||||
|
||||
return false; // Exclude otherwise
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function toggleLeftDrawer () {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await axios.post('/auth/logout');
|
||||
authStore.logout(); // Use the store action to update state
|
||||
// No need to manually push, router guard should redirect
|
||||
// router.push({ name: 'login' });
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Logout failed. Please try again.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
36
src/pages/LandingPage.vue
Normal file
36
src/pages/LandingPage.vue
Normal 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
103
src/pages/LoginPage.vue
Normal 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>
|
275
src/pages/PasskeyManagementPage.vue
Normal file
275
src/pages/PasskeyManagementPage.vue
Normal 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
135
src/pages/RegisterPage.vue
Normal 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>
|
|
@ -1,6 +1,7 @@
|
|||
import { defineRouter } from '#q-app/wrappers'
|
||||
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import routes from './routes'
|
||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
|
@ -11,7 +12,7 @@ import routes from './routes'
|
|||
* with the Router instance.
|
||||
*/
|
||||
|
||||
export default defineRouter(function (/* { store, ssrContext } */) {
|
||||
export default defineRouter(function ({ store /* { store, ssrContext } */ }) {
|
||||
const createHistory = process.env.SERVER
|
||||
? createMemoryHistory
|
||||
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
|
||||
|
@ -26,5 +27,45 @@ export default defineRouter(function (/* { store, ssrContext } */) {
|
|||
history: createHistory(process.env.VUE_ROUTER_BASE)
|
||||
})
|
||||
|
||||
// Navigation Guard using Pinia store
|
||||
Router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore(store); // Get store instance
|
||||
|
||||
// Ensure auth status is checked, especially on first load or refresh
|
||||
// This check might be better placed in App.vue or a boot file
|
||||
if (!authStore.user && !authStore.loading) { // Check only if user is not loaded and not already loading
|
||||
try {
|
||||
await authStore.checkAuthStatus();
|
||||
} catch (e) {
|
||||
console.error("Initial auth check failed", e);
|
||||
// Decide how to handle initial check failure (e.g., proceed, redirect to error page)
|
||||
}
|
||||
}
|
||||
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
|
||||
const publicPages = ['/login', '/register'];
|
||||
const isPublicPage = publicPages.includes(to.path);
|
||||
const isAuthenticated = authStore.isAuthenticated; // Get status from store
|
||||
|
||||
console.log('Store Auth status:', isAuthenticated);
|
||||
console.log('Navigating to:', to.path);
|
||||
console.log('Requires auth:', requiresAuth);
|
||||
console.log('Is public page:', isPublicPage);
|
||||
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
// If route requires auth and user is not authenticated, redirect to login
|
||||
console.log('Redirecting to login (requires auth, not authenticated)');
|
||||
next('/login');
|
||||
} else if (isPublicPage && isAuthenticated) {
|
||||
// If user is authenticated and tries to access login/register, redirect to home
|
||||
console.log('Redirecting to home (public page, authenticated)');
|
||||
next('/');
|
||||
} else {
|
||||
// Otherwise, allow navigation
|
||||
console.log('Allowing navigation');
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
return Router
|
||||
})
|
||||
|
|
|
@ -3,15 +3,101 @@ const routes = [
|
|||
path: '/',
|
||||
component: () => import('layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'home', component: () => import('pages/FormListPage.vue') },
|
||||
{ path: 'forms', name: 'formList', component: () => import('pages/FormListPage.vue') },
|
||||
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue') },
|
||||
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true },
|
||||
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true },
|
||||
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true },
|
||||
{ path: 'mantis-summaries', name: 'mantisSummaries', component: () => import('pages/MantisSummariesPage.vue') },
|
||||
{ path: 'email-summaries', name: 'emailSummaries', component: () => import('pages/EmailSummariesPage.vue') },
|
||||
{ path: 'settings', name: 'settings', component: () => import('pages/SettingsPage.vue') }
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('pages/LandingPage.vue'),
|
||||
meta: { requiresAuth: false } // Keep home accessible, but don't show in nav
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('pages/LoginPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
navGroup: 'noAuth', // Show only when logged out
|
||||
icon: 'login',
|
||||
title: 'Login',
|
||||
caption: 'Access your account'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('pages/RegisterPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
navGroup: 'noAuth', // Show only when logged out
|
||||
icon: 'person_add',
|
||||
title: 'Register',
|
||||
caption: 'Create an account'
|
||||
}
|
||||
},
|
||||
// Add a new route specifically for managing passkeys when logged in
|
||||
{
|
||||
path: '/passkeys',
|
||||
name: 'passkeys',
|
||||
component: () => import('pages/PasskeyManagementPage.vue'), // Assuming this page exists or will be created
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
navGroup: 'auth', // Show only when logged in
|
||||
icon: 'key',
|
||||
title: 'Passkeys',
|
||||
caption: 'Manage your passkeys'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forms',
|
||||
name: 'formList',
|
||||
component: () => import('pages/FormListPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
navGroup: 'auth', // Show only when logged in
|
||||
icon: 'list_alt',
|
||||
title: 'Forms',
|
||||
caption: 'View existing forms'
|
||||
}
|
||||
},
|
||||
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue'), meta: { requiresAuth: true } }, // Not in nav
|
||||
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
|
||||
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
|
||||
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
|
||||
{
|
||||
path: 'mantis-summaries',
|
||||
name: 'mantisSummaries',
|
||||
component: () => import('pages/MantisSummariesPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
navGroup: 'auth', // Show only when logged in
|
||||
icon: 'summarize',
|
||||
title: 'Mantis Summaries',
|
||||
caption: 'View daily summaries'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'email-summaries',
|
||||
name: 'emailSummaries',
|
||||
component: () => import('pages/EmailSummariesPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
navGroup: 'auth', // Show only when logged in
|
||||
icon: 'email',
|
||||
title: 'Email Summaries',
|
||||
caption: 'View email summaries'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('pages/SettingsPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
navGroup: 'auth', // Show only when logged in
|
||||
icon: 'settings',
|
||||
title: 'Settings',
|
||||
caption: 'Manage application settings'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
|
39
src/stores/auth.js
Normal file
39
src/stores/auth.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import axios from 'axios';
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false, // Optional: track loading state
|
||||
error: null, // Optional: track errors
|
||||
}),
|
||||
actions: {
|
||||
async checkAuthStatus() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const res = await axios.get('/auth/check-auth');
|
||||
if (res.data.isAuthenticated) {
|
||||
this.isAuthenticated = true;
|
||||
this.user = res.data.user;
|
||||
} else {
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check authentication status:', error);
|
||||
this.error = 'Could not verify login status.';
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
// Action to manually set user as logged out (e.g., after logout)
|
||||
logout() {
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
}
|
||||
},
|
||||
});
|
20
src/stores/index.js
Normal file
20
src/stores/index.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { defineStore } from '#q-app/wrappers'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
/*
|
||||
* If not building with SSR mode, you can
|
||||
* directly export the Store instantiation;
|
||||
*
|
||||
* The function below can be async too; either use
|
||||
* async/await or return a Promise which resolves
|
||||
* with the Store instance.
|
||||
*/
|
||||
|
||||
export default defineStore((/* { ssrContext } */) => {
|
||||
const pinia = createPinia()
|
||||
|
||||
// You can add Pinia plugins here
|
||||
// pinia.use(SomePiniaPlugin)
|
||||
|
||||
return pinia
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue