Force line endings and whitespace, and revamp logout via introduction of a new profile component.
This commit is contained in:
parent
f6df79d83f
commit
0e491ecabe
31 changed files with 4870 additions and 4797 deletions
|
@ -1,220 +1,220 @@
|
|||
<template>
|
||||
<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-input
|
||||
outlined
|
||||
v-model="form.description"
|
||||
label="Form Description"
|
||||
type="textarea"
|
||||
autogrow
|
||||
/>
|
||||
|
||||
<q-separator class="q-my-lg" />
|
||||
|
||||
<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="(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-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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
|
||||
const form = ref({
|
||||
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 removeCategory(index)
|
||||
{
|
||||
form.value.categories.splice(index, 1);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.bordered {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.rounded-borders {
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<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-input
|
||||
outlined
|
||||
v-model="form.description"
|
||||
label="Form Description"
|
||||
type="textarea"
|
||||
autogrow
|
||||
/>
|
||||
|
||||
<q-separator class="q-my-lg" />
|
||||
|
||||
<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="(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-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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
|
||||
const form = ref({
|
||||
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 removeCategory(index)
|
||||
{
|
||||
form.value.categories.splice(index, 1);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.bordered {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.rounded-borders {
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,285 +1,285 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="text-h4 q-mb-md">
|
||||
Edit Form
|
||||
</div>
|
||||
|
||||
<q-form
|
||||
v-if="!loading && form"
|
||||
@submit.prevent="updateForm"
|
||||
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-separator class="q-my-lg" />
|
||||
|
||||
<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 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="field.id || fieldIndex"
|
||||
class="q-ml-md q-mb-sm"
|
||||
>
|
||||
<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"
|
||||
label="Field Description (Optional)"
|
||||
outlined
|
||||
dense
|
||||
autogrow
|
||||
class="q-mt-xs q-mb-xl"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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' }"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
<div v-else-if="loading">
|
||||
<q-spinner-dots
|
||||
color="primary"
|
||||
size="40px"
|
||||
/>
|
||||
Loading form details...
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-negative"
|
||||
>
|
||||
Failed to load form details.
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const route = useRoute(); // Use useRoute if needed, though id is from props
|
||||
|
||||
const form = ref(null); // Initialize as null
|
||||
const loading = ref(true);
|
||||
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
|
||||
const submitting = ref(false);
|
||||
|
||||
async function fetchForm()
|
||||
{
|
||||
loading.value = true;
|
||||
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 =>
|
||||
{
|
||||
cat.fields = cat.fields || [];
|
||||
});
|
||||
form.value = response.data;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching form details:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to load form details.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
form.value = null; // Indicate failure
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchForm);
|
||||
|
||||
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)
|
||||
{
|
||||
form.value.categories.splice(index, 1);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
|
||||
}
|
||||
|
||||
async function updateForm()
|
||||
{
|
||||
submitting.value = true;
|
||||
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))
|
||||
// We don't need to send the form ID in the body as it's in the URL
|
||||
|
||||
await axios.put(`/api/forms/${props.id}`, payload);
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: `Form "${form.value.title}" updated successfully!`,
|
||||
icon: 'check_circle'
|
||||
});
|
||||
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
|
||||
}
|
||||
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({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: message,
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bordered {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.rounded-borders {
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<q-page padding>
|
||||
<div class="text-h4 q-mb-md">
|
||||
Edit Form
|
||||
</div>
|
||||
|
||||
<q-form
|
||||
v-if="!loading && form"
|
||||
@submit.prevent="updateForm"
|
||||
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-separator class="q-my-lg" />
|
||||
|
||||
<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 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="field.id || fieldIndex"
|
||||
class="q-ml-md q-mb-sm"
|
||||
>
|
||||
<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"
|
||||
label="Field Description (Optional)"
|
||||
outlined
|
||||
dense
|
||||
autogrow
|
||||
class="q-mt-xs q-mb-xl"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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' }"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
<div v-else-if="loading">
|
||||
<q-spinner-dots
|
||||
color="primary"
|
||||
size="40px"
|
||||
/>
|
||||
Loading form details...
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-negative"
|
||||
>
|
||||
Failed to load form details.
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const route = useRoute(); // Use useRoute if needed, though id is from props
|
||||
|
||||
const form = ref(null); // Initialize as null
|
||||
const loading = ref(true);
|
||||
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
|
||||
const submitting = ref(false);
|
||||
|
||||
async function fetchForm()
|
||||
{
|
||||
loading.value = true;
|
||||
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 =>
|
||||
{
|
||||
cat.fields = cat.fields || [];
|
||||
});
|
||||
form.value = response.data;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching form details:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to load form details.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
form.value = null; // Indicate failure
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchForm);
|
||||
|
||||
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)
|
||||
{
|
||||
form.value.categories.splice(index, 1);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
|
||||
}
|
||||
|
||||
async function updateForm()
|
||||
{
|
||||
submitting.value = true;
|
||||
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))
|
||||
// We don't need to send the form ID in the body as it's in the URL
|
||||
|
||||
await axios.put(`/api/forms/${props.id}`, payload);
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: `Form "${form.value.title}" updated successfully!`,
|
||||
icon: 'check_circle'
|
||||
});
|
||||
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
|
||||
}
|
||||
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({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: message,
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bordered {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.rounded-borders {
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,223 +1,223 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<q-inner-loading :showing="loading">
|
||||
<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>
|
||||
|
||||
<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
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
/>
|
||||
<q-input
|
||||
v-else-if="field.type === 'number'"
|
||||
outlined
|
||||
type="number"
|
||||
v-model.number="responses[field.id]"
|
||||
:label="field.label"
|
||||
/>
|
||||
<q-input
|
||||
v-else-if="field.type === 'date'"
|
||||
outlined
|
||||
type="date"
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
stack-label
|
||||
/>
|
||||
<q-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
outlined
|
||||
type="textarea"
|
||||
autogrow
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
/>
|
||||
<q-checkbox
|
||||
v-else-if="field.type === 'boolean'"
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
left-label
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<!-- Add other field types as needed -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-form>
|
||||
</div>
|
||||
<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 #action>
|
||||
<q-btn
|
||||
flat
|
||||
color="white"
|
||||
label="Back to Forms"
|
||||
:to="{ name: 'formList' }"
|
||||
/>
|
||||
</template>
|
||||
</q-banner>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = ref(null);
|
||||
const responses = reactive({}); // Use reactive for dynamic properties
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
|
||||
async function fetchFormDetails()
|
||||
{
|
||||
loading.value = true;
|
||||
form.value = null; // Reset form data
|
||||
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 =>
|
||||
{
|
||||
responses[field.id] = null; // Initialize all fields to null or default
|
||||
});
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error fetching form ${props.id}:`, error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to load form details.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitResponse()
|
||||
{
|
||||
submitting.value = true;
|
||||
try
|
||||
{
|
||||
// Basic check if any response is provided (optional)
|
||||
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
|
||||
// if (!hasResponse) {
|
||||
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
|
||||
// return;
|
||||
// }
|
||||
|
||||
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: 'Response submitted successfully!',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
// Optionally redirect or clear form
|
||||
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
|
||||
// Or clear the form:
|
||||
// Object.keys(responses).forEach(key => { responses[key] = null; });
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error submitting response:', error);
|
||||
const message = error.response?.data?.error || 'Failed to submit response.';
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: message,
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchFormDetails);
|
||||
</script>
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-inner-loading :showing="loading">
|
||||
<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>
|
||||
|
||||
<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
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
/>
|
||||
<q-input
|
||||
v-else-if="field.type === 'number'"
|
||||
outlined
|
||||
type="number"
|
||||
v-model.number="responses[field.id]"
|
||||
:label="field.label"
|
||||
/>
|
||||
<q-input
|
||||
v-else-if="field.type === 'date'"
|
||||
outlined
|
||||
type="date"
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
stack-label
|
||||
/>
|
||||
<q-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
outlined
|
||||
type="textarea"
|
||||
autogrow
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
/>
|
||||
<q-checkbox
|
||||
v-else-if="field.type === 'boolean'"
|
||||
v-model="responses[field.id]"
|
||||
:label="field.label"
|
||||
left-label
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<!-- Add other field types as needed -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-form>
|
||||
</div>
|
||||
<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 #action>
|
||||
<q-btn
|
||||
flat
|
||||
color="white"
|
||||
label="Back to Forms"
|
||||
:to="{ name: 'formList' }"
|
||||
/>
|
||||
</template>
|
||||
</q-banner>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = ref(null);
|
||||
const responses = reactive({}); // Use reactive for dynamic properties
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
|
||||
async function fetchFormDetails()
|
||||
{
|
||||
loading.value = true;
|
||||
form.value = null; // Reset form data
|
||||
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 =>
|
||||
{
|
||||
responses[field.id] = null; // Initialize all fields to null or default
|
||||
});
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error fetching form ${props.id}:`, error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to load form details.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitResponse()
|
||||
{
|
||||
submitting.value = true;
|
||||
try
|
||||
{
|
||||
// Basic check if any response is provided (optional)
|
||||
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
|
||||
// if (!hasResponse) {
|
||||
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
|
||||
// return;
|
||||
// }
|
||||
|
||||
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: 'Response submitted successfully!',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
// Optionally redirect or clear form
|
||||
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
|
||||
// Or clear the form:
|
||||
// Object.keys(responses).forEach(key => { responses[key] = null; });
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error submitting response:', error);
|
||||
const message = error.response?.data?.error || 'Failed to submit response.';
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: message,
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchFormDetails);
|
||||
</script>
|
||||
|
|
|
@ -1,187 +1,187 @@
|
|||
<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>
|
||||
|
||||
<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-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"
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<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-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
const $q = useQuasar();
|
||||
const forms = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchForms()
|
||||
{
|
||||
loading.value = true;
|
||||
try
|
||||
{
|
||||
const response = await axios.get('/api/forms');
|
||||
forms.value = response.data;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching forms:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to load forms. Please try again later.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add function to handle delete confirmation
|
||||
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.',
|
||||
cancel: true,
|
||||
persistent: true,
|
||||
ok: {
|
||||
label: 'Delete',
|
||||
color: 'negative',
|
||||
flat: false
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
flat: true
|
||||
}
|
||||
}).onOk(() =>
|
||||
{
|
||||
deleteForm(id);
|
||||
});
|
||||
}
|
||||
|
||||
// Add function to call the delete API
|
||||
async function deleteForm(id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await axios.delete(`/api/forms/${id}`);
|
||||
forms.value = forms.value.filter(form => form.id !== id);
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: 'Form deleted successfully.',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error deleting form ${id}:`, error);
|
||||
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: errorMessage,
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add function to format date
|
||||
function formatDate(date)
|
||||
{
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
|
||||
onMounted(fetchForms);
|
||||
</script>
|
||||
<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>
|
||||
|
||||
<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-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"
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<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-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
const $q = useQuasar();
|
||||
const forms = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchForms()
|
||||
{
|
||||
loading.value = true;
|
||||
try
|
||||
{
|
||||
const response = await axios.get('/api/forms');
|
||||
forms.value = response.data;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching forms:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to load forms. Please try again later.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add function to handle delete confirmation
|
||||
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.',
|
||||
cancel: true,
|
||||
persistent: true,
|
||||
ok: {
|
||||
label: 'Delete',
|
||||
color: 'negative',
|
||||
flat: false
|
||||
},
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
flat: true
|
||||
}
|
||||
}).onOk(() =>
|
||||
{
|
||||
deleteForm(id);
|
||||
});
|
||||
}
|
||||
|
||||
// Add function to call the delete API
|
||||
async function deleteForm(id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await axios.delete(`/api/forms/${id}`);
|
||||
forms.value = forms.value.filter(form => form.id !== id);
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: 'Form deleted successfully.',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error deleting form ${id}:`, error);
|
||||
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: errorMessage,
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add function to format date
|
||||
function formatDate(date)
|
||||
{
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
|
||||
onMounted(fetchForms);
|
||||
</script>
|
||||
|
|
|
@ -1,250 +1,250 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<q-inner-loading :showing="loading">
|
||||
<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>
|
||||
|
||||
<!-- Add Search Input -->
|
||||
<q-input
|
||||
v-if="responses.length > 0"
|
||||
outlined
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filterText"
|
||||
placeholder="Search responses..."
|
||||
class="q-mb-md"
|
||||
>
|
||||
<template #append>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-table
|
||||
v-if="responses.length > 0"
|
||||
:rows="formattedResponses"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
flat
|
||||
bordered
|
||||
separator="cell"
|
||||
wrap-cells
|
||||
:filter="filterText"
|
||||
>
|
||||
<template #body-cell-submittedAt="props">
|
||||
<q-td :props="props">
|
||||
{{ new Date(props.value).toLocaleString() }}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Slot for Actions column -->
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="download"
|
||||
color="primary"
|
||||
@click="downloadResponsePdf(props.row.id)"
|
||||
aria-label="Download PDF"
|
||||
>
|
||||
<q-tooltip>Download PDF</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<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 #avatar>
|
||||
<q-icon name="error" />
|
||||
</template>
|
||||
Form not found or could not load responses.
|
||||
<template #action>
|
||||
<q-btn
|
||||
flat
|
||||
color="white"
|
||||
label="Back to Forms"
|
||||
:to="{ name: 'formList' }"
|
||||
/>
|
||||
</template>
|
||||
</q-banner>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
const componentProps = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const formTitle = ref('');
|
||||
const responses = ref([]);
|
||||
const columns = ref([]);
|
||||
const loading = ref(true);
|
||||
const filterText = ref('');
|
||||
|
||||
// Fetch both form details (for title and field labels/order) and responses
|
||||
async function fetchData()
|
||||
{
|
||||
loading.value = true;
|
||||
formTitle.value = '';
|
||||
responses.value = [];
|
||||
columns.value = [];
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch form details first to get the structure
|
||||
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 =>
|
||||
{
|
||||
generatedColumns.push({
|
||||
name: `field_${field.id}`,
|
||||
label: field.label,
|
||||
field: row => row.values[field.id]?.value ?? '',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
columns.value = generatedColumns;
|
||||
|
||||
// Add Actions column
|
||||
columns.value.push({
|
||||
name: 'actions',
|
||||
label: 'Actions',
|
||||
field: 'actions',
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
// Fetch responses
|
||||
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
|
||||
responses.value = responsesResponse.data;
|
||||
|
||||
}
|
||||
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
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Computed property to match the structure expected by QTable rows
|
||||
const formattedResponses = computed(() =>
|
||||
{
|
||||
return responses.value.map(response =>
|
||||
{
|
||||
const row = {
|
||||
id: response.id,
|
||||
submittedAt: response.submittedAt,
|
||||
// Flatten values for direct access by field function in columns
|
||||
values: response.values
|
||||
};
|
||||
return row;
|
||||
});
|
||||
});
|
||||
|
||||
// Function to download a single response as PDF
|
||||
async function downloadResponsePdf(responseId)
|
||||
{
|
||||
try
|
||||
{
|
||||
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
|
||||
responseType: 'blob', // Important for handling file downloads
|
||||
});
|
||||
|
||||
// Create a URL for the blob
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Try to get filename from content-disposition header
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `response-${responseId}.pdf`; // Default filename
|
||||
if (contentDisposition)
|
||||
{
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
|
||||
if (filenameMatch && filenameMatch.length > 1)
|
||||
{
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
link.parentNode.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: `Downloaded ${filename}`,
|
||||
icon: 'check_circle'
|
||||
});
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error downloading PDF for response ${responseId}:`, error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to download PDF.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-inner-loading :showing="loading">
|
||||
<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>
|
||||
|
||||
<!-- Add Search Input -->
|
||||
<q-input
|
||||
v-if="responses.length > 0"
|
||||
outlined
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filterText"
|
||||
placeholder="Search responses..."
|
||||
class="q-mb-md"
|
||||
>
|
||||
<template #append>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-table
|
||||
v-if="responses.length > 0"
|
||||
:rows="formattedResponses"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
flat
|
||||
bordered
|
||||
separator="cell"
|
||||
wrap-cells
|
||||
:filter="filterText"
|
||||
>
|
||||
<template #body-cell-submittedAt="props">
|
||||
<q-td :props="props">
|
||||
{{ new Date(props.value).toLocaleString() }}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Slot for Actions column -->
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="download"
|
||||
color="primary"
|
||||
@click="downloadResponsePdf(props.row.id)"
|
||||
aria-label="Download PDF"
|
||||
>
|
||||
<q-tooltip>Download PDF</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<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 #avatar>
|
||||
<q-icon name="error" />
|
||||
</template>
|
||||
Form not found or could not load responses.
|
||||
<template #action>
|
||||
<q-btn
|
||||
flat
|
||||
color="white"
|
||||
label="Back to Forms"
|
||||
:to="{ name: 'formList' }"
|
||||
/>
|
||||
</template>
|
||||
</q-banner>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'boot/axios';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
const componentProps = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const formTitle = ref('');
|
||||
const responses = ref([]);
|
||||
const columns = ref([]);
|
||||
const loading = ref(true);
|
||||
const filterText = ref('');
|
||||
|
||||
// Fetch both form details (for title and field labels/order) and responses
|
||||
async function fetchData()
|
||||
{
|
||||
loading.value = true;
|
||||
formTitle.value = '';
|
||||
responses.value = [];
|
||||
columns.value = [];
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch form details first to get the structure
|
||||
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 =>
|
||||
{
|
||||
generatedColumns.push({
|
||||
name: `field_${field.id}`,
|
||||
label: field.label,
|
||||
field: row => row.values[field.id]?.value ?? '',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
columns.value = generatedColumns;
|
||||
|
||||
// Add Actions column
|
||||
columns.value.push({
|
||||
name: 'actions',
|
||||
label: 'Actions',
|
||||
field: 'actions',
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
// Fetch responses
|
||||
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
|
||||
responses.value = responsesResponse.data;
|
||||
|
||||
}
|
||||
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
|
||||
{
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Computed property to match the structure expected by QTable rows
|
||||
const formattedResponses = computed(() =>
|
||||
{
|
||||
return responses.value.map(response =>
|
||||
{
|
||||
const row = {
|
||||
id: response.id,
|
||||
submittedAt: response.submittedAt,
|
||||
// Flatten values for direct access by field function in columns
|
||||
values: response.values
|
||||
};
|
||||
return row;
|
||||
});
|
||||
});
|
||||
|
||||
// Function to download a single response as PDF
|
||||
async function downloadResponsePdf(responseId)
|
||||
{
|
||||
try
|
||||
{
|
||||
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
|
||||
responseType: 'blob', // Important for handling file downloads
|
||||
});
|
||||
|
||||
// Create a URL for the blob
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Try to get filename from content-disposition header
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `response-${responseId}.pdf`; // Default filename
|
||||
if (contentDisposition)
|
||||
{
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
|
||||
if (filenameMatch && filenameMatch.length > 1)
|
||||
{
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
link.parentNode.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: `Downloaded ${filename}`,
|
||||
icon: 'check_circle'
|
||||
});
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error downloading PDF for response ${responseId}:`, error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to download PDF.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
|
|
|
@ -1,54 +1,54 @@
|
|||
<template>
|
||||
<template>
|
||||
<q-page class="landing-page column items-center q-pa-md">
|
||||
<div class="hero text-center q-pa-xl full-width">
|
||||
<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>
|
||||
</h1>
|
||||
<p class="text-h6 text-grey-8 q-mb-lg">
|
||||
The all-in-one tool designed for StyleTech Developers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
</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-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..?'
|
||||
]);
|
||||
|
||||
</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>
|
|
@ -1,130 +1,130 @@
|
|||
<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 'boot/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('/api/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('/api/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>
|
||||
<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 'boot/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('/api/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('/api/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>
|
||||
|
|
|
@ -1,248 +1,248 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div class="text-h6">
|
||||
Mantis Summaries
|
||||
</div>
|
||||
<q-btn
|
||||
label="Generate Today's 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 #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 #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"
|
||||
>
|
||||
<div 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 'boot/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
|
||||
{
|
||||
const response = await axios.get('/api/mantis-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 Mantis 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
|
||||
{
|
||||
await axios.post('/api/mantis-summaries/generate');
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
icon: 'check_circle',
|
||||
message: '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 Mantis summary:', err);
|
||||
generationError.value = err.response?.data?.error || 'Failed to start 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;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Add any specific styles if needed */
|
||||
</style>
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div class="text-h6">
|
||||
Mantis Summaries
|
||||
</div>
|
||||
<q-btn
|
||||
label="Generate Today's 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 #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 #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"
|
||||
>
|
||||
<div 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 'boot/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
|
||||
{
|
||||
const response = await axios.get('/api/mantis-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 Mantis 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
|
||||
{
|
||||
await axios.post('/api/mantis-summaries/generate');
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
icon: 'check_circle',
|
||||
message: '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 Mantis summary:', err);
|
||||
generationError.value = err.response?.data?.error || 'Failed to start 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;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Add any specific styles if needed */
|
||||
</style>
|
||||
|
|
|
@ -1,371 +1,371 @@
|
|||
<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 'boot/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('/api/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('/api/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('/api/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(`/api/auth/passkeys/${credentialID}`);
|
||||
deleteSuccessMessage.value = 'Passkey deleted successfully.';
|
||||
fetchPasskeys(); // Refresh the list
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error deleting passkey:', error);
|
||||
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleteLoading.value = null; // Clear loading state
|
||||
}
|
||||
}
|
||||
|
||||
// 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('/api/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
|
||||
}
|
||||
}
|
||||
|
||||
<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 'boot/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('/api/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('/api/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('/api/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(`/api/auth/passkeys/${credentialID}`);
|
||||
deleteSuccessMessage.value = 'Passkey deleted successfully.';
|
||||
fetchPasskeys(); // Refresh the list
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error deleting passkey:', error);
|
||||
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleteLoading.value = null; // Clear loading state
|
||||
}
|
||||
}
|
||||
|
||||
// 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('/api/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>
|
|
@ -1,179 +1,179 @@
|
|||
<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 'boot/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('/api/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('/api/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>
|
||||
<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 'boot/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('/api/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('/api/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,202 +1,202 @@
|
|||
<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>
|
||||
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<q-input
|
||||
v-model="mantisPrompt"
|
||||
type="textarea"
|
||||
filled
|
||||
autogrow
|
||||
label="Mantis Prompt"
|
||||
:loading="loadingPrompt"
|
||||
:disable="savingPrompt"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
label="Save Prompt"
|
||||
color="primary"
|
||||
@click="saveMantisPrompt"
|
||||
:loading="savingPrompt"
|
||||
:disable="!mantisPrompt || loadingPrompt"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<q-input
|
||||
v-model="emailPrompt"
|
||||
type="textarea"
|
||||
filled
|
||||
autogrow
|
||||
label="Email Prompt"
|
||||
:loading="loadingEmailPrompt"
|
||||
:disable="savingEmailPrompt"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
label="Save Prompt"
|
||||
color="primary"
|
||||
@click="saveEmailPrompt"
|
||||
:loading="savingEmailPrompt"
|
||||
:disable="!emailPrompt || loadingEmailPrompt"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import axios from 'boot/axios';
|
||||
|
||||
const $q = useQuasar();
|
||||
|
||||
const mantisPrompt = ref('');
|
||||
const loadingPrompt = ref(false);
|
||||
const savingPrompt = ref(false);
|
||||
|
||||
const fetchMantisPrompt = async() =>
|
||||
{
|
||||
loadingPrompt.value = true;
|
||||
try
|
||||
{
|
||||
const response = await axios.get('/api/settings/mantisPrompt');
|
||||
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to load Mantis prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveMantisPrompt = async() =>
|
||||
{
|
||||
savingPrompt.value = true;
|
||||
try
|
||||
{
|
||||
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: 'Mantis prompt updated successfully.',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error saving Mantis prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to save Mantis prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const emailPrompt = ref('');
|
||||
const loadingEmailPrompt = ref(false);
|
||||
const savingEmailPrompt = ref(false);
|
||||
|
||||
const fetchEmailPrompt = async() =>
|
||||
{
|
||||
loadingEmailPrompt.value = true;
|
||||
try
|
||||
{
|
||||
const response = await axios.get('/api/settings/emailPrompt');
|
||||
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Email prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to load Email prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingEmailPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveEmailPrompt = async() =>
|
||||
{
|
||||
savingEmailPrompt.value = true;
|
||||
try
|
||||
{
|
||||
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: 'Email prompt updated successfully.',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error saving Email prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to save Email prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingEmailPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() =>
|
||||
{
|
||||
fetchMantisPrompt();
|
||||
fetchEmailPrompt();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any specific styles if needed */
|
||||
</style>
|
||||
<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>
|
||||
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<q-input
|
||||
v-model="mantisPrompt"
|
||||
type="textarea"
|
||||
filled
|
||||
autogrow
|
||||
label="Mantis Prompt"
|
||||
:loading="loadingPrompt"
|
||||
:disable="savingPrompt"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
label="Save Prompt"
|
||||
color="primary"
|
||||
@click="saveMantisPrompt"
|
||||
:loading="savingPrompt"
|
||||
:disable="!mantisPrompt || loadingPrompt"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<q-input
|
||||
v-model="emailPrompt"
|
||||
type="textarea"
|
||||
filled
|
||||
autogrow
|
||||
label="Email Prompt"
|
||||
:loading="loadingEmailPrompt"
|
||||
:disable="savingEmailPrompt"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
label="Save Prompt"
|
||||
color="primary"
|
||||
@click="saveEmailPrompt"
|
||||
:loading="savingEmailPrompt"
|
||||
:disable="!emailPrompt || loadingEmailPrompt"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import axios from 'boot/axios';
|
||||
|
||||
const $q = useQuasar();
|
||||
|
||||
const mantisPrompt = ref('');
|
||||
const loadingPrompt = ref(false);
|
||||
const savingPrompt = ref(false);
|
||||
|
||||
const fetchMantisPrompt = async() =>
|
||||
{
|
||||
loadingPrompt.value = true;
|
||||
try
|
||||
{
|
||||
const response = await axios.get('/api/settings/mantisPrompt');
|
||||
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to load Mantis prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveMantisPrompt = async() =>
|
||||
{
|
||||
savingPrompt.value = true;
|
||||
try
|
||||
{
|
||||
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: 'Mantis prompt updated successfully.',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error saving Mantis prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to save Mantis prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const emailPrompt = ref('');
|
||||
const loadingEmailPrompt = ref(false);
|
||||
const savingEmailPrompt = ref(false);
|
||||
|
||||
const fetchEmailPrompt = async() =>
|
||||
{
|
||||
loadingEmailPrompt.value = true;
|
||||
try
|
||||
{
|
||||
const response = await axios.get('/api/settings/emailPrompt');
|
||||
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Email prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to load Email prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingEmailPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveEmailPrompt = async() =>
|
||||
{
|
||||
savingEmailPrompt.value = true;
|
||||
try
|
||||
{
|
||||
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: 'Email prompt updated successfully.',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error saving Email prompt:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: 'Failed to save Email prompt setting.',
|
||||
icon: 'report_problem'
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingEmailPrompt.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() =>
|
||||
{
|
||||
fetchMantisPrompt();
|
||||
fetchEmailPrompt();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any specific styles if needed */
|
||||
</style>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue