Added linting and enforced code styling.

This commit is contained in:
Cameron Redmore 2025-04-25 08:14:48 +01:00
parent 8655eae39c
commit 86967b26cd
37 changed files with 3356 additions and 1875 deletions

View file

@ -1,201 +0,0 @@
<template>
<q-page padding>
<q-card flat bordered>
<q-card-section class="row items-center justify-between">
<div class="text-h6">Email Summaries</div>
<q-btn
label="Generate Email Summary"
color="primary"
@click="generateSummary"
:loading="generating"
:disable="generating"
/>
</q-card-section>
<q-separator />
<q-card-section v-if="generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-icon name="error" />
</template>
{{ generationError }}
</q-banner>
</q-card-section>
<q-card-section v-if="loading">
<q-spinner-dots size="40px" color="primary" />
<span class="q-ml-md">Loading summaries...</span>
</q-card-section>
<q-card-section v-if="error && !generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-icon name="error" />
</template>
{{ error }}
</q-banner>
</q-card-section>
<q-list separator v-if="!loading && !error && summaries.length > 0">
<q-item v-for="summary in summaries" :key="summary.id">
<q-item-section>
<q-item-label class="text-weight-bold">{{ formatDate(summary.summaryDate) }}</q-item-label>
<q-item-label caption>Generated: {{ formatDateTime(summary.generatedAt) }}</q-item-label>
<q-item-label class="q-mt-sm markdown-content" v-html="parseMarkdown(summary.summaryText)"></q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section v-if="totalPages > 1" class="flex flex-center q-mt-md">
<q-pagination
v-model="currentPage"
:max="totalPages"
@update:model-value="fetchSummaries"
direction-links
flat
color="primary"
active-color="primary"
/>
</q-card-section>
<q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">No summaries found.</div>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { date, useQuasar } from 'quasar'; // Import useQuasar
import axios from 'axios';
import { marked } from 'marked';
const $q = useQuasar(); // Initialize Quasar plugin usage
const summaries = ref([]);
const loading = ref(true);
const error = ref(null);
const generating = ref(false); // State for generation button
const generationError = ref(null); // State for generation error
const currentPage = ref(1);
const itemsPerPage = ref(10); // Or your desired page size
const totalItems = ref(0);
// Create a custom renderer
const renderer = new marked.Renderer();
const linkRenderer = renderer.link;
renderer.link = (href, title, text) => {
const html = linkRenderer.call(renderer, href, title, text);
// Add target="_blank" to the link
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
};
const fetchSummaries = async (page = 1) => {
loading.value = true;
error.value = null;
try {
// *** CHANGED API ENDPOINT ***
const response = await axios.get(`/api/email-summaries`, {
params: {
page: page,
limit: itemsPerPage.value
}
});
summaries.value = response.data.summaries;
totalItems.value = response.data.total;
currentPage.value = page;
} catch (err) {
console.error('Error fetching Email summaries:', err);
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
} finally {
loading.value = false;
}
};
const generateSummary = async () => {
generating.value = true;
generationError.value = null;
error.value = null; // Clear previous loading errors
try {
// *** CHANGED API ENDPOINT ***
await axios.post('/api/email-summaries/generate');
$q.notify({
color: 'positive',
icon: 'check_circle',
// *** CHANGED MESSAGE ***
message: 'Email summary generation started successfully. It may take a few moments to appear.',
});
// Optionally, refresh the list after a short delay or immediately
// Consider that generation might be async on the backend
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
} catch (err) {
console.error('Error generating Email summary:', err);
// *** CHANGED MESSAGE ***
generationError.value = err.response?.data?.error || 'Failed to start email summary generation.';
$q.notify({
color: 'negative',
icon: 'error',
message: generationError.value,
});
} finally {
generating.value = false;
}
};
const formatDate = (dateString) => {
// Assuming dateString is YYYY-MM-DD
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
};
const formatDateTime = (dateTimeString) => {
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
};
const parseMarkdown = (markdownText) => {
if (!markdownText) return '';
// Use the custom renderer with marked
return marked(markdownText, { renderer });
};
const totalPages = computed(() => {
return Math.ceil(totalItems.value / itemsPerPage.value);
});
onMounted(() => {
fetchSummaries(currentPage.value);
});
</script>
<style scoped>
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin-top: 1em;
margin-bottom: 1em;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.markdown-content :deep(th) {
background-color: rgba(0, 0, 0, 0.25);
}
.markdown-content :deep(a) {
color: var(--q-primary);
text-decoration: none;
}
.markdown-content :deep(a:hover) {
text-decoration: underline;
}
/* Add any specific styles if needed */
</style>

View file

@ -5,7 +5,10 @@
404
</div>
<div class="text-h2" style="opacity:.4">
<div
class="text-h2"
style="opacity:.4"
>
Oops. Nothing here...
</div>

View file

@ -1,54 +1,140 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">Create New Form</div>
<q-page padding>
<div class="text-h4 q-mb-md">
Create New Form
</div>
<q-form @submit.prevent="createForm" class="q-gutter-md">
<q-input outlined v-model="form.title" label="Form Title *" lazy-rules
:rules="[val => val && val.length > 0 || 'Please enter a title']" />
<q-form
@submit.prevent="createForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input outlined v-model="form.description" label="Form Description" type="textarea" autogrow />
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">Categories & Fields</div>
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div v-for="(category, catIndex) in form.categories" :key="catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders">
<div class="row items-center q-mb-sm">
<q-input outlined dense v-model="category.name" :label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm" lazy-rules
:rules="[val => val && val.length > 0 || 'Category name required']" />
<q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)"
title="Remove Category" />
</div>
<div
v-for="(category, catIndex) in form.categories"
:key="catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div v-for="(field, fieldIndex) in category.fields" :key="fieldIndex"
class="q-ml-md q-mb-sm field-item">
<div class="row items-center q-gutter-sm">
<q-input outlined dense v-model="field.label" label="Field Label *" class="col" lazy-rules
:rules="[val => val && val.length > 0 || 'Field label required']" />
<q-select outlined dense v-model="field.type" :options="fieldTypes" label="Field Type *"
class="col-auto" style="min-width: 150px;" lazy-rules
:rules="[val => !!val || 'Field type required']" />
<q-btn flat round dense icon="delete" color="negative"
@click="removeField(catIndex, fieldIndex)" title="Remove Field" />
</div>
<q-input v-model="field.description" outlined dense label="Field Description (Optional)" autogrow
class="q-mt-xs q-mb-xl" hint="This description will appear below the field label on the form." />
</div>
<q-btn color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" />
</div>
<div
v-for="(field, fieldIndex) in category.fields"
:key="fieldIndex"
class="q-ml-md q-mb-sm field-item"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
outlined
dense
label="Field Description (Optional)"
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn color="secondary" label="Add Category" @click="addCategory" />
<q-btn
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<q-separator class="q-my-lg" />
<div>
<q-btn label="Create Form" type="submit" color="primary" :loading="submitting" />
<q-btn label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" />
</div>
</q-form>
</q-page>
<div>
<q-btn
label="Create Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</q-page>
</template>
<script setup>
@ -61,55 +147,65 @@ const $q = useQuasar();
const router = useRouter();
const form = ref({
title: '',
description: '',
categories: [
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
]
title: '',
description: '',
categories: [
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
]
});
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
function addCategory() {
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
function addCategory()
{
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
}
function removeCategory(index) {
form.value.categories.splice(index, 1);
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex) {
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
function addField(catIndex)
{
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex) {
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function createForm() {
submitting.value = true;
try {
const response = await axios.post('/api/forms', form.value);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" created successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' });
} catch (error) {
console.error('Error creating form:', error);
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
} finally {
submitting.value = false;
}
async function createForm()
{
submitting.value = true;
try
{
const response = await axios.post('/api/forms', form.value);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" created successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' });
}
catch (error)
{
console.error('Error creating form:', error);
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
</script>

View file

@ -1,8 +1,14 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">Edit Form</div>
<div class="text-h4 q-mb-md">
Edit Form
</div>
<q-form v-if="!loading && form" @submit.prevent="updateForm" class="q-gutter-md">
<q-form
v-if="!loading && form"
@submit.prevent="updateForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
@ -21,25 +27,45 @@
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">Categories & Fields</div>
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div v-for="(category, catIndex) in form.categories" :key="category.id || catIndex" class="q-mb-lg q-pa-md bordered rounded-borders">
<div
v-for="(category, catIndex) in form.categories"
:key="category.id || catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined dense
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Category name required']"
/>
<q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)" title="Remove Category" />
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div v-for="(field, fieldIndex) in category.fields" :key="field.id || fieldIndex" class="q-ml-md q-mb-sm">
<div
v-for="(field, fieldIndex) in category.fields"
:key="field.id || fieldIndex"
class="q-ml-md q-mb-sm"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined dense
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
@ -47,7 +73,8 @@
:rules="[ val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined dense
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
@ -56,7 +83,15 @@
lazy-rules
:rules="[ val => !!val || 'Field type required']"
/>
<q-btn flat round dense icon="delete" color="negative" @click="removeField(catIndex, fieldIndex)" title="Remove Field" />
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
@ -68,23 +103,53 @@
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn outline color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" />
<q-btn
outline
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn outline color="secondary" label="Add Category" @click="addCategory" />
<q-btn
outline
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<div>
<q-btn outline label="Update Form" type="submit" color="primary" :loading="submitting"/>
<q-btn outline label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" />
<q-btn
outline
label="Update Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
<div v-else-if="loading">
<q-spinner-dots color="primary" size="40px" />
<div v-else-if="loading">
<q-spinner-dots
color="primary"
size="40px"
/>
Loading form details...
</div>
<div v-else class="text-negative">
<div
v-else
class="text-negative"
>
Failed to load form details.
</div>
</q-page>
@ -112,17 +177,22 @@ const loading = ref(true);
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
async function fetchForm() {
async function fetchForm()
{
loading.value = true;
try {
try
{
const response = await axios.get(`/api/forms/${props.id}`);
// Ensure categories and fields exist, even if empty
response.data.categories = response.data.categories || [];
response.data.categories.forEach(cat => {
response.data.categories.forEach(cat =>
{
cat.fields = cat.fields || [];
});
form.value = response.data;
} catch (error) {
}
catch (error)
{
console.error('Error fetching form details:', error);
$q.notify({
color: 'negative',
@ -131,38 +201,48 @@ async function fetchForm() {
icon: 'report_problem'
});
form.value = null; // Indicate failure
} finally {
}
finally
{
loading.value = false;
}
}
onMounted(fetchForm);
function addCategory() {
if (!form.value.categories) {
function addCategory()
{
if (!form.value.categories)
{
form.value.categories = [];
}
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
}
function removeCategory(index) {
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex) {
if (!form.value.categories[catIndex].fields) {
form.value.categories[catIndex].fields = [];
}
function addField(catIndex)
{
if (!form.value.categories[catIndex].fields)
{
form.value.categories[catIndex].fields = [];
}
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex) {
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function updateForm() {
async function updateForm()
{
submitting.value = true;
try {
try
{
// Prepare payload, potentially removing temporary IDs if any were added client-side
const payload = JSON.parse(JSON.stringify(form.value));
// The backend PUT expects title, description, categories (with name, fields (with label, type, description))
@ -176,7 +256,9 @@ async function updateForm() {
icon: 'check_circle'
});
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
} catch (error) {
}
catch (error)
{
console.error('Error updating form:', error);
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
$q.notify({
@ -185,7 +267,9 @@ async function updateForm() {
message: message,
icon: 'report_problem'
});
} finally {
}
finally
{
submitting.value = false;
}
}

View file

@ -1,20 +1,47 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" />
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && form">
<div class="text-h4 q-mb-xs">{{ form.title }}</div>
<div class="text-subtitle1 text-grey q-mb-lg">{{ form.description }}</div>
<div class="text-h4 q-mb-xs">
{{ form.title }}
</div>
<div class="text-subtitle1 text-grey q-mb-lg">
{{ form.description }}
</div>
<q-form @submit.prevent="submitResponse" class="q-gutter-md">
<div v-for="category in form.categories" :key="category.id" class="q-mb-lg">
<div class="text-h6 q-mb-sm">{{ category.name }}</div>
<div v-for="field in category.fields" :key="field.id" class="q-mb-md">
<q-item-label class="q-mb-xs">{{ field.label }}</q-item-label>
<q-item-label caption v-if="field.description" class="q-mb-xs text-grey-7">{{ field.description }}</q-item-label>
<q-form
@submit.prevent="submitResponse"
class="q-gutter-md"
>
<div
v-for="category in form.categories"
:key="category.id"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ category.name }}
</div>
<div
v-for="field in category.fields"
:key="field.id"
class="q-mb-md"
>
<q-item-label class="q-mb-xs">
{{ field.label }}
</q-item-label>
<q-item-label
caption
v-if="field.description"
class="q-mb-xs text-grey-7"
>
{{ field.description }}
</q-item-label>
<q-input
v-if="field.type === 'text'"
outlined
@ -28,7 +55,7 @@
v-model.number="responses[field.id]"
:label="field.label"
/>
<q-input
<q-input
v-else-if="field.type === 'date'"
outlined
type="date"
@ -58,19 +85,39 @@
<q-separator class="q-my-lg" />
<div>
<q-btn outline label="Submit Response" type="submit" color="primary" :loading="submitting"/>
<q-btn outline label="Cancel" type="reset" color="default" class="q-ml-sm" :to="{ name: 'formList' }" />
</div>
<q-btn
outline
label="Submit Response"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="default"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</div>
<q-banner v-else-if="!loading && !form" class="bg-negative text-white">
<template v-slot:avatar>
<q-banner
v-else-if="!loading && !form"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not be loaded.
<template v-slot:action>
<q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" />
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
@ -97,19 +144,25 @@ const responses = reactive({}); // Use reactive for dynamic properties
const loading = ref(true);
const submitting = ref(false);
async function fetchFormDetails() {
async function fetchFormDetails()
{
loading.value = true;
form.value = null; // Reset form data
try {
try
{
const response = await axios.get(`/api/forms/${props.id}`);
form.value = response.data;
// Initialize responses object based on fields
form.value.categories.forEach(cat => {
cat.fields.forEach(field => {
form.value.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
responses[field.id] = null; // Initialize all fields to null or default
});
});
} catch (error) {
}
catch (error)
{
console.error(`Error fetching form ${props.id}:`, error);
$q.notify({
color: 'negative',
@ -117,14 +170,18 @@ async function fetchFormDetails() {
message: 'Failed to load form details.',
icon: 'report_problem'
});
} finally {
}
finally
{
loading.value = false;
}
}
async function submitResponse() {
async function submitResponse()
{
submitting.value = true;
try {
try
{
// Basic check if any response is provided (optional)
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
// if (!hasResponse) {
@ -144,7 +201,9 @@ async function submitResponse() {
// Or clear the form:
// Object.keys(responses).forEach(key => { responses[key] = null; });
} catch (error) {
}
catch (error)
{
console.error('Error submitting response:', error);
const message = error.response?.data?.error || 'Failed to submit response.';
$q.notify({
@ -153,7 +212,9 @@ async function submitResponse() {
message: message,
icon: 'report_problem'
});
} finally {
}
finally
{
submitting.value = false;
}
}

View file

@ -1,38 +1,96 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">Forms</div>
<q-btn outline label="Create New Form" color="primary" :to="{ name: 'formCreate' }" />
<div class="text-h4">
Forms
</div>
<q-btn
outline
label="Create New Form"
color="primary"
:to="{ name: 'formCreate' }"
/>
</div>
<q-list bordered separator v-if="forms.length > 0">
<q-item v-for="form in forms" :key="form.id">
<q-list
bordered
separator
v-if="forms.length > 0"
>
<q-item
v-for="form in forms"
:key="form.id"
>
<q-item-section>
<q-item-label>{{ form.title }}</q-item-label>
<q-item-label caption>{{ form.description || 'No description' }}</q-item-label>
<q-item-label caption>Created: {{ formatDate(form.createdAt) }}</q-item-label>
<q-item-label caption>
{{ form.description || 'No description' }}
</q-item-label>
<q-item-label caption>
Created: {{ formatDate(form.createdAt) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-sm">
<q-btn flat round dense icon="edit_note" color="info" :to="{ name: 'formFill', params: { id: form.id } }" title="Fill Form" />
<q-btn flat round dense icon="visibility" color="secondary" :to="{ name: 'formResponses', params: { id: form.id } }" title="View Responses" />
<q-btn flat round dense icon="edit" color="warning" :to="{ name: 'formEdit', params: { id: form.id } }" title="Edit Form" />
<q-btn flat round dense icon="delete" color="negative" @click.stop="confirmDeleteForm(form.id)" title="Delete Form" />
<q-btn
flat
round
dense
icon="edit_note"
color="info"
:to="{ name: 'formFill', params: { id: form.id } }"
title="Fill Form"
/>
<q-btn
flat
round
dense
icon="visibility"
color="secondary"
:to="{ name: 'formResponses', params: { id: form.id } }"
title="View Responses"
/>
<q-btn
flat
round
dense
icon="edit"
color="warning"
:to="{ name: 'formEdit', params: { id: form.id } }"
title="Edit Form"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click.stop="confirmDeleteForm(form.id)"
title="Delete Form"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<q-banner v-else class="bg-info text-white">
<template v-slot:avatar>
<q-icon name="info" color="white" />
<q-banner
v-else
class="bg-info text-white"
>
<template #avatar>
<q-icon
name="info"
color="white"
/>
</template>
No forms created yet. Click the button above to create your first form.
</q-banner>
<q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
</q-page>
</template>
@ -45,12 +103,16 @@ const $q = useQuasar();
const forms = ref([]);
const loading = ref(false);
async function fetchForms() {
async function fetchForms()
{
loading.value = true;
try {
try
{
const response = await axios.get('/api/forms');
forms.value = response.data;
} catch (error) {
}
catch (error)
{
console.error('Error fetching forms:', error);
$q.notify({
color: 'negative',
@ -58,13 +120,16 @@ async function fetchForms() {
message: 'Failed to load forms. Please try again later.',
icon: 'report_problem'
});
} finally {
}
finally
{
loading.value = false;
}
}
// Add function to handle delete confirmation
function confirmDeleteForm(id) {
function confirmDeleteForm(id)
{
$q.dialog({
title: 'Confirm Delete',
message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
@ -79,14 +144,17 @@ function confirmDeleteForm(id) {
label: 'Cancel',
flat: true
}
}).onOk(() => {
}).onOk(() =>
{
deleteForm(id);
});
}
// Add function to call the delete API
async function deleteForm(id) {
try {
async function deleteForm(id)
{
try
{
await axios.delete(`/api/forms/${id}`);
forms.value = forms.value.filter(form => form.id !== id);
$q.notify({
@ -95,7 +163,9 @@ async function deleteForm(id) {
message: 'Form deleted successfully.',
icon: 'check_circle'
});
} catch (error) {
}
catch (error)
{
console.error(`Error deleting form ${id}:`, error);
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
$q.notify({
@ -108,7 +178,8 @@ async function deleteForm(id) {
}
// Add function to format date
function formatDate(date) {
function formatDate(date)
{
return new Date(date).toLocaleString();
}

View file

@ -1,12 +1,17 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" />
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && formTitle">
<div class="row justify-between items-center q-mb-md">
<div class="text-h4">Responses for: {{ formTitle }}</div>
<div class="text-h4">
Responses for: {{ formTitle }}
</div>
</div>
<!-- Add Search Input -->
@ -19,7 +24,7 @@
placeholder="Search responses..."
class="q-mb-md"
>
<template v-slot:append>
<template #append>
<q-icon name="search" />
</template>
</q-input>
@ -29,22 +34,25 @@
:rows="formattedResponses"
:columns="columns"
row-key="id"
flat bordered
flat
bordered
separator="cell"
wrap-cells
:filter="filterText"
>
<template v-slot:body-cell-submittedAt="props">
<template #body-cell-submittedAt="props">
<q-td :props="props">
{{ new Date(props.value).toLocaleString() }}
</q-td>
</template>
<!-- Slot for Actions column -->
<template v-slot:body-cell-actions="props">
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
flat dense round
flat
dense
round
icon="download"
color="primary"
@click="downloadResponsePdf(props.row.id)"
@ -54,24 +62,36 @@
</q-btn>
</q-td>
</template>
</q-table>
<q-banner v-else class="">
<template v-slot:avatar>
<q-icon name="info" color="info" />
<q-banner
v-else
class=""
>
<template #avatar>
<q-icon
name="info"
color="info"
/>
</template>
No responses have been submitted for this form yet.
</q-banner>
</div>
<q-banner v-else-if="!loading && !formTitle" class="bg-negative text-white">
<template v-slot:avatar>
<q-banner
v-else-if="!loading && !formTitle"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not load responses.
<template v-slot:action>
<q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" />
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
@ -81,9 +101,8 @@
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
const props = defineProps({
const componentProps = defineProps({
id: {
type: [String, Number],
required: true
@ -93,34 +112,37 @@ const props = defineProps({
const $q = useQuasar();
const formTitle = ref('');
const responses = ref([]);
const columns = ref([]); // Columns will be generated dynamically
const columns = ref([]);
const loading = ref(true);
const filterText = ref(''); // Add ref for filter text
const filterText = ref('');
// Fetch both form details (for title and field labels/order) and responses
async function fetchData() {
async function fetchData()
{
loading.value = true;
formTitle.value = '';
responses.value = [];
columns.value = [];
try {
try
{
// Fetch form details first to get the structure
const formDetailsResponse = await axios.get(`/api/forms/${props.id}`);
const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
const form = formDetailsResponse.data;
formTitle.value = form.title;
// Generate columns based on form fields in correct order
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
form.categories.forEach(cat => {
cat.fields.forEach(field => {
form.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
generatedColumns.push({
name: `field_${field.id}`, // Unique name for column
name: `field_${field.id}`,
label: field.label,
field: row => row.values[field.id]?.value ?? '', // Access nested value safely
field: row => row.values[field.id]?.value ?? '',
align: 'left',
sortable: true,
// Add formatting based on field.type if needed
});
});
});
@ -135,25 +157,31 @@ async function fetchData() {
});
// Fetch responses
const responsesResponse = await axios.get(`/api/forms/${props.id}/responses`);
responses.value = responsesResponse.data; // API already groups them
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
responses.value = responsesResponse.data;
} catch (error) {
console.error(`Error fetching data for form ${props.id}:`, error);
}
catch (error)
{
console.error(`Error fetching data for form ${componentProps.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form responses.',
icon: 'report_problem'
});
} finally {
}
finally
{
loading.value = false;
}
}
// Computed property to match the structure expected by QTable rows
const formattedResponses = computed(() => {
return responses.value.map(response => {
const formattedResponses = computed(() =>
{
return responses.value.map(response =>
{
const row = {
id: response.id,
submittedAt: response.submittedAt,
@ -165,8 +193,10 @@ const formattedResponses = computed(() => {
});
// Function to download a single response as PDF
async function downloadResponsePdf(responseId) {
try {
async function downloadResponsePdf(responseId)
{
try
{
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
responseType: 'blob', // Important for handling file downloads
});
@ -179,9 +209,11 @@ async function downloadResponsePdf(responseId) {
// Try to get filename from content-disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `response-${responseId}.pdf`; // Default filename
if (contentDisposition) {
if (contentDisposition)
{
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1) {
if (filenameMatch && filenameMatch.length > 1)
{
filename = filenameMatch[1];
}
}
@ -201,7 +233,9 @@ async function downloadResponsePdf(responseId) {
icon: 'check_circle'
});
} catch (error) {
}
catch (error)
{
console.error(`Error downloading PDF for response ${responseId}:`, error);
$q.notify({
color: 'negative',

View file

@ -1,22 +1,40 @@
<template>
<q-page class="landing-page column items-center q-pa-md">
<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="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>
<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>
@ -27,10 +45,10 @@ const $q = useQuasar();
const currentYear = ref(new Date().getFullYear());
const features = ref([
'Auatomated Daily Reports',
'Deep Mantis Integration',
'Easy Authentication',
'And more..?'
'Auatomated Daily Reports',
'Deep Mantis Integration',
'Easy Authentication',
'And more..?'
]);
</script>

View file

@ -2,7 +2,9 @@
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">Login</div>
<div class="text-h6">
Login
</div>
</q-card-section>
<q-card-section>
@ -23,11 +25,20 @@
@click="handleLogin"
:loading="loading"
/>
<div v-if="errorMessage" class="text-negative q-mt-md">{{ errorMessage }}</div>
<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-btn
flat
label="Don't have an account? Register"
to="/register"
/>
</q-card-actions>
</q-card>
</q-page>
@ -46,11 +57,13 @@ const errorMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
async function handleLogin() {
async function handleLogin()
{
loading.value = true;
errorMessage.value = '';
try {
try
{
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-authentication-options', {
username: username.value || undefined, // Send username if provided
@ -65,38 +78,52 @@ async function handleLogin() {
authenticationResponse: authResp,
});
if (verificationRes.data.verified) {
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 {
}
else
{
errorMessage.value = 'Authentication failed.';
// Optionally update store state on failure
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = 'Authentication failed.';
}
} catch (error) {
}
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}`;
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 {
}
finally
{
loading.value = false;
}
}

View file

@ -1,8 +1,13 @@
<template>
<q-page padding>
<q-card flat bordered>
<q-card
flat
bordered
>
<q-card-section class="row items-center justify-between">
<div class="text-h6">Mantis Summaries</div>
<div class="text-h6">
Mantis Summaries
</div>
<q-btn
label="Generate Today's Summary"
color="primary"
@ -15,8 +20,11 @@
<q-separator />
<q-card-section v-if="generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ generationError }}
@ -24,30 +32,53 @@
</q-card-section>
<q-card-section v-if="loading">
<q-spinner-dots size="40px" color="primary" />
<q-spinner-dots
size="40px"
color="primary"
/>
<span class="q-ml-md">Loading summaries...</span>
</q-card-section>
<q-card-section v-if="error && !generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ error }}
</q-banner>
</q-card-section>
<q-list separator v-if="!loading && !error && summaries.length > 0">
<q-item v-for="summary in summaries" :key="summary.id">
<q-list
separator
v-if="!loading && !error && summaries.length > 0"
>
<q-item
v-for="summary in summaries"
:key="summary.id"
>
<q-item-section>
<q-item-label class="text-weight-bold">{{ formatDate(summary.summaryDate) }}</q-item-label>
<q-item-label caption>Generated: {{ formatDateTime(summary.generatedAt) }}</q-item-label>
<q-item-label class="q-mt-sm markdown-content" v-html="parseMarkdown(summary.summaryText)"></q-item-label>
<q-item-label class="text-weight-bold">
{{ formatDate(summary.summaryDate) }}
</q-item-label>
<q-item-label caption>
Generated: {{ formatDateTime(summary.generatedAt) }}
</q-item-label>
<q-item-label
class="q-mt-sm markdown-content"
>
<div v-html="parseMarkdown(summary.content)" />
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section v-if="totalPages > 1" class="flex flex-center q-mt-md">
<q-card-section
v-if="totalPages > 1"
class="flex flex-center q-mt-md"
>
<q-pagination
v-model="currentPage"
:max="totalPages"
@ -59,10 +90,11 @@
/>
</q-card-section>
<q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">No summaries found.</div>
<q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">
No summaries found.
</div>
</q-card-section>
</q-card>
</q-page>
</template>
@ -86,18 +118,21 @@ const totalItems = ref(0);
// Create a custom renderer
const renderer = new marked.Renderer();
const linkRenderer = renderer.link;
renderer.link = (href, title, text) => {
renderer.link = (href, title, text) =>
{
const html = linkRenderer.call(renderer, href, title, text);
// Add target="_blank" to the link
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
};
const fetchSummaries = async (page = 1) => {
const fetchSummaries = async(page = 1) =>
{
loading.value = true;
error.value = null;
try {
const response = await axios.get(`/api/mantis-summaries`, {
try
{
const response = await axios.get('/api/mantis-summaries', {
params: {
page: page,
limit: itemsPerPage.value
@ -106,19 +141,25 @@ const fetchSummaries = async (page = 1) => {
summaries.value = response.data.summaries;
totalItems.value = response.data.total;
currentPage.value = page;
} catch (err) {
}
catch (err)
{
console.error('Error fetching Mantis summaries:', err);
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
} finally {
}
finally
{
loading.value = false;
}
};
const generateSummary = async () => {
const generateSummary = async() =>
{
generating.value = true;
generationError.value = null;
error.value = null; // Clear previous loading errors
try {
try
{
await axios.post('/api/mantis-summaries/generate');
$q.notify({
color: 'positive',
@ -128,39 +169,48 @@ const generateSummary = async () => {
// Optionally, refresh the list after a short delay or immediately
// Consider that generation might be async on the backend
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
} catch (err) {
}
catch (err)
{
console.error('Error generating Mantis summary:', err);
generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
$q.notify({
$q.notify({
color: 'negative',
icon: 'error',
message: generationError.value,
});
} finally {
}
finally
{
generating.value = false;
}
};
const formatDate = (dateString) => {
const formatDate = (dateString) =>
{
// Assuming dateString is YYYY-MM-DD
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
};
const formatDateTime = (dateTimeString) => {
const formatDateTime = (dateTimeString) =>
{
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
};
const parseMarkdown = (markdownText) => {
const parseMarkdown = (markdownText) =>
{
if (!markdownText) return '';
// Use the custom renderer with marked
return marked(markdownText, { renderer });
};
const totalPages = computed(() => {
const totalPages = computed(() =>
{
return Math.ceil(totalItems.value / itemsPerPage.value);
});
onMounted(() => {
onMounted(() =>
{
fetchSummaries(currentPage.value);
});
</script>

View file

@ -1,9 +1,11 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">Passkey Management</div>
<div class="text-h4">
Passkey Management
</div>
<div>
<q-btn
<q-btn
label="Identify Passkey"
color="secondary"
class="q-mx-md q-mt-md"
@ -11,8 +13,8 @@
:loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn"
outline
/>
<q-btn
/>
<q-btn
label="Register New Passkey"
color="primary"
class="q-mx-md q-mt-md"
@ -20,17 +22,31 @@
: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-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>
<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"
@ -39,14 +55,19 @@
>
<q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<q-item-label caption v-if="identifiedPasskeyId === passkey.credentialID">
<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">
<q-item-section
side
class="row no-wrap items-center"
>
<!-- Delete Button -->
<q-btn
flat
@ -61,16 +82,44 @@
</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>
<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>
@ -100,7 +149,8 @@ const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys() {
async function fetchPasskeys()
{
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
@ -108,37 +158,50 @@ async function fetchPasskeys() {
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try {
try
{
const response = await axios.get('/auth/passkeys');
passkeys.value = response.data || [];
} catch (error) {
}
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 {
}
finally
{
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async () => {
onMounted(async() =>
{
let initialAuthError = '';
if (!authStore.isAuthenticated) {
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error) {
initialAuthError = `Authentication error: ${authStore.error}`;
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 {
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) {
async function handleRegister()
{
if (!isLoggedIn.value || !username.value)
{
registerErrorMessage.value = 'User not authenticated.';
return;
}
@ -150,7 +213,8 @@ async function handleRegister() {
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try {
try
{
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: username.value, // Use username from store
@ -165,33 +229,48 @@ async function handleRegister() {
registrationResponse: regResp,
});
if (verificationRes.data.verified) {
if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
} else {
}
else
{
registerErrorMessage.value = 'Passkey verification failed.';
}
} catch (error) {
}
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') {
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 {
}
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 {
}
finally
{
registerLoading.value = false;
}
}
// Handle deleting a passkey
async function handleDelete(credentialID) {
async function handleDelete(credentialID)
{
if (!credentialID) return;
// Optional: Add a confirmation dialog here
@ -207,21 +286,28 @@ async function handleDelete(credentialID) {
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try {
try
{
await axios.delete(`/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list
} catch (error) {
}
catch (error)
{
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
} finally {
}
finally
{
deleteLoading.value = null; // Clear loading state
}
}
// Handle identifying a passkey
async function handleIdentify() {
if (!isLoggedIn.value) {
async function handleIdentify()
{
if (!isLoggedIn.value)
{
identifyErrorMessage.value = 'You must be logged in.';
return;
}
@ -235,7 +321,8 @@ async function handleIdentify() {
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
try {
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
@ -252,22 +339,31 @@ async function handleIdentify() {
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;
}
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) {
}
catch (error)
{
console.error('Identification error:', error);
identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError') {
if (error.name === 'NotAllowedError')
{
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
} else {
}
else
{
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
}
} finally {
}
finally
{
identifyLoading.value = null; // Clear loading state
}
}

View file

@ -3,7 +3,9 @@
<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>
<div class="text-h6">
{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}
</div>
</q-card-section>
<q-card-section>
@ -27,13 +29,28 @@
: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>
<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 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>
@ -58,24 +75,32 @@ 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) {
onMounted(async() =>
{
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error) {
errorMessage.value = authStore.error;
if (authStore.error)
{
errorMessage.value = authStore.error;
}
}
if (!isLoggedIn.value) {
if (!isLoggedIn.value)
{
username.value = ''; // Clear username if not logged in
} else {
}
else
{
username.value = authStore.user?.username || ''; // Use username from store if logged in
}
});
async function handleRegister() {
async function handleRegister()
{
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
if (!currentUsername) {
if (!currentUsername)
{
errorMessage.value = 'Username is missing.';
return;
}
@ -83,7 +108,8 @@ async function handleRegister() {
errorMessage.value = '';
successMessage.value = '';
try {
try
{
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: currentUsername, // Use username from store
@ -98,37 +124,55 @@ async function handleRegister() {
registrationResponse: regResp,
});
if (verificationRes.data.verified) {
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) {
if (!isLoggedIn.value)
{
// Redirect to login page only if they weren't logged in
setTimeout(() => {
setTimeout(() =>
{
router.push('/login');
}, 2000);
} else {
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
else
{
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
} else {
}
else
{
errorMessage.value = 'Registration failed.';
}
} catch (error) {
}
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') {
// 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 {
}
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 {
}
finally
{
loading.value = false;
}
}

View file

@ -1,11 +1,21 @@
<template>
<q-page padding>
<div class="q-gutter-md" style="max-width: 800px; margin: auto;">
<h5 class="q-mt-none q-mb-md">Settings</h5>
<div
class="q-gutter-md"
style="max-width: 800px; margin: auto;"
>
<h5 class="q-mt-none q-mb-md">
Settings
</h5>
<q-card flat bordered>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">Mantis Summary Prompt</div>
<div class="text-h6">
Mantis Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
</div>
@ -30,9 +40,14 @@
</q-card-actions>
</q-card>
<q-card flat bordered>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">Email Summary Prompt</div>
<div class="text-h6">
Email Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
</div>
@ -55,8 +70,7 @@
:disable="!emailPrompt || loadingEmailPrompt"
/>
</q-card-actions>
</q-card>
</q-card>
</div>
</q-page>
</template>
@ -72,40 +86,52 @@ const mantisPrompt = ref('');
const loadingPrompt = ref(false);
const savingPrompt = ref(false);
const fetchMantisPrompt = async () => {
const fetchMantisPrompt = async() =>
{
loadingPrompt.value = true;
try {
try
{
const response = await axios.get('/api/settings/mantisPrompt');
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
} catch (error) {
}
catch (error)
{
console.error('Error fetching Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Mantis prompt setting.',
icon: 'report_problem'
});
} finally {
}
finally
{
loadingPrompt.value = false;
}
};
const saveMantisPrompt = async () => {
const saveMantisPrompt = async() =>
{
savingPrompt.value = true;
try {
try
{
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
$q.notify({
color: 'positive',
message: 'Mantis prompt updated successfully.',
icon: 'check_circle'
});
} catch (error) {
}
catch (error)
{
console.error('Error saving Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Mantis prompt setting.',
icon: 'report_problem'
});
} finally {
}
finally
{
savingPrompt.value = false;
}
};
@ -114,45 +140,58 @@ const emailPrompt = ref('');
const loadingEmailPrompt = ref(false);
const savingEmailPrompt = ref(false);
const fetchEmailPrompt = async () => {
const fetchEmailPrompt = async() =>
{
loadingEmailPrompt.value = true;
try {
try
{
const response = await axios.get('/api/settings/emailPrompt');
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
} catch (error) {
}
catch (error)
{
console.error('Error fetching Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Email prompt setting.',
icon: 'report_problem'
});
} finally {
}
finally
{
loadingEmailPrompt.value = false;
}
};
const saveEmailPrompt = async () => {
const saveEmailPrompt = async() =>
{
savingEmailPrompt.value = true;
try {
try
{
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
$q.notify({
color: 'positive',
message: 'Email prompt updated successfully.',
icon: 'check_circle'
});
} catch (error) {
}
catch (error)
{
console.error('Error saving Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Email prompt setting.',
icon: 'report_problem'
});
} finally {
}
finally
{
savingEmailPrompt.value = false;
}
};
onMounted(() => {
onMounted(() =>
{
fetchMantisPrompt();
fetchEmailPrompt();
});